diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ec3d660205..56b3ebe87b18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: run: dotnet build -c Debug -warnaserror osu.Desktop.slnf - name: Test - run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" + run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0 shell: pwsh # Attempt to upload results even if test fails. diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 022da0a2ea1d..03fd21829d67 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -15,6 +15,8 @@ M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Gen M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks. P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. Use Task.GetResultSafely() to ensure we avoid deadlocks. M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever. +M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. +M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead. diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index b8604169aa11..936808f38b8d 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 41174525794b..35e774217281 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 0b119c868029..c1044965b5a9 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 41174525794b..35e774217281 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,9 +9,9 @@ false - + - + diff --git a/osu.Android.props b/osu.Android.props index dd263d6aaaaf..f251e8ee719a 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,11 +51,11 @@ - - + + - + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index d9ad95f96adb..3ee1b3da3094 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -137,12 +137,13 @@ public override void SetHost(GameHost host) { base.SetHost(host); - var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); - var desktopWindow = (SDL2DesktopWindow)host.Window; + var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); + if (iconStream != null) + desktopWindow.SetIconFromStream(iconStream); + desktopWindow.CursorState |= CursorState.Hidden; - desktopWindow.SetIconFromStream(iconStream); desktopWindow.Title = Name; desktopWindow.DragDrop += f => fileDrop(new[] { f }); } diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 36ffd3b5b662..d62d422f3356 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -7,9 +7,9 @@ - + - + diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs new file mode 100644 index 000000000000..538fc7fac6d5 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public class TestSceneCatchModFlashlight : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.25f)] + [TestCase(1.5f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new CatchModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new CatchModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs index cbf6e8f20276..cf6a8169c47c 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs @@ -1,10 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Mods; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests @@ -12,11 +17,11 @@ namespace osu.Game.Rulesets.Catch.Tests [TestFixture] public class TestSceneCatchTouchInput : OsuTestScene { - private CatchTouchInputMapper catchTouchInputMapper = null!; - - [SetUpSteps] - public void SetUpSteps() + [Test] + public void TestBasic() { + CatchTouchInputMapper catchTouchInputMapper = null!; + AddStep("create input overlay", () => { Child = new CatchInputManager(new CatchRuleset().RulesetInfo) @@ -32,12 +37,30 @@ public void SetUpSteps() } }; }); + + AddStep("show overlay", () => catchTouchInputMapper.Show()); } [Test] - public void TestBasic() + public void TestWithoutRelax() { - AddStep("show overlay", () => catchTouchInputMapper.Show()); + AddStep("create drawable ruleset without relax mod", () => + { + Child = new DrawableCatchRuleset(new CatchRuleset(), new CatchBeatmap(), new List()); + }); + AddUntilStep("wait for load", () => Child.IsLoaded); + AddAssert("check touch input is shown", () => this.ChildrenOfType().Any()); + } + + [Test] + public void TestWithRelax() + { + AddStep("create drawable ruleset with relax mod", () => + { + Child = new DrawableCatchRuleset(new CatchRuleset(), new CatchBeatmap(), new List { new CatchModRelax() }); + }); + AddUntilStep("wait for load", () => Child.IsLoaded); + AddAssert("check touch input is not shown", () => !this.ChildrenOfType().Any()); } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs index 7f513728affa..f3161f32be98 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Objects; @@ -12,6 +12,8 @@ using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; using osuTK; using osuTK.Graphics; @@ -19,15 +21,28 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneComboCounter : CatchSkinnableTestScene { - private ScoreProcessor scoreProcessor; + private ScoreProcessor scoreProcessor = null!; private Color4 judgedObjectColour = Color4.White; + private readonly Bindable showHud = new Bindable(true); + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.CacheAs(new TestPlayer + { + ShowingOverlayComponents = { BindTarget = showHud }, + }); + } + [SetUp] public void SetUp() => Schedule(() => { scoreProcessor = new ScoreProcessor(new CatchRuleset()); + showHud.Value = true; + SetContents(_ => new CatchComboDisplay { Anchor = Anchor.Centre, @@ -51,9 +66,15 @@ public void TestCatchComboCounter() 1f ); }); + + AddStep("set hud to never show", () => showHud.Value = false); + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 5); + + AddStep("set hud to show", () => showHud.Value = true); + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 5); } - private void performJudgement(HitResult type, Judgement judgement = null) + private void performJudgement(HitResult type, Judgement? judgement = null) { var judgedObject = new DrawableFruit(new Fruit()) { AccentColour = { Value = judgedObjectColour } }; diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index d45f8a969244..c9db8246158b 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -1,9 +1,9 @@  - + - + WinExe diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs index 58f493b4b899..a0a11424d0f5 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs @@ -36,5 +36,7 @@ public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObjec return base.CreateHitObjectBlueprintFor(hitObject); } + + protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); } } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs new file mode 100644 index 000000000000..0a0f91c781f2 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs @@ -0,0 +1,49 @@ +// 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.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchEditorPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + { + protected override Container Content => content; + private readonly Container content; + + public CatchEditorPlayfieldAdjustmentContainer() + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + Size = new Vector2(0.8f, 0.9f); + + InternalChild = new ScalingContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = content = new Container { RelativeSizeAxes = Axes.Both }, + }; + } + + private class ScalingContainer : Container + { + public ScalingContainer() + { + RelativeSizeAxes = Axes.Y; + Width = CatchPlayfield.WIDTH; + } + + protected override void Update() + { + base.Update(); + + Scale = new Vector2(Math.Min(Parent.ChildSize.X / CatchPlayfield.WIDTH, Parent.ChildSize.Y / CatchPlayfield.HEIGHT)); + Height = 1 / Scale.Y; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index f31dc3ef9c9e..220bc49203e6 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -10,10 +11,11 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Edit; @@ -21,7 +23,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; -using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -33,10 +34,14 @@ public class CatchHitObjectComposer : DistancedHitObjectComposer private CatchDistanceSnapGrid distanceSnapGrid; - private readonly Bindable distanceSnapToggle = new Bindable(); - private InputManager inputManager; + private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1) + { + MinValue = 1, + MaxValue = 10, + }; + public CatchHitObjectComposer(CatchRuleset ruleset) : base(ruleset) { @@ -51,7 +56,10 @@ private void load() LayerBelowRuleset.Add(new PlayfieldBorder { - RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = CatchPlayfield.HEIGHT, PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }); @@ -70,6 +78,19 @@ protected override void LoadComplete() inputManager = GetContainingInputManager(); } + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + { + // osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified. + // Therefore this functionality is not currently used. + // + // The implementation below is probably correct but should be checked if/when exposed via controls. + + float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); + float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX); + + return actualDistance / expectedDistance; + } + protected override void Update() { base.Update(); @@ -77,8 +98,30 @@ protected override void Update() updateDistanceSnapGrid(); } + public override bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + // Note that right now these are hard to use as the default key bindings conflict with existing editor key bindings. + // In the future we will want to expose this via UI and potentially change the key bindings to be editor-specific. + // May be worth considering standardising "zoom" behaviour with what the timeline uses (ie. alt-wheel) but that may cause new conflicts. + case GlobalAction.IncreaseScrollSpeed: + this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint); + break; + + case GlobalAction.DecreaseScrollSpeed: + this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint); + break; + } + + return base.OnPressed(e); + } + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => - new DrawableCatchEditorRuleset(ruleset, beatmap, mods); + new DrawableCatchEditorRuleset(ruleset, beatmap, mods) + { + TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, } + }; protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { @@ -87,11 +130,6 @@ protected override DrawableRuleset CreateDrawableRuleset(Ruleset new BananaShowerCompositionTool() }; - protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] - { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) - }); - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { var result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); @@ -163,7 +201,7 @@ private PalpableCatchHitObject getDistanceSnapGridSourceHitObject() private void updateDistanceSnapGrid() { - if (distanceSnapToggle.Value != TernaryState.True) + if (DistanceSnapToggle.Value != TernaryState.True) { distanceSnapGrid.Hide(); return; diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs index c81afafae577..67238f66d463 100644 --- a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs +++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs @@ -4,6 +4,7 @@ #nullable disable using System.Collections.Generic; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -13,11 +14,24 @@ namespace osu.Game.Rulesets.Catch.Edit { public class DrawableCatchEditorRuleset : DrawableCatchRuleset { + public readonly BindableDouble TimeRangeMultiplier = new BindableDouble(1); + public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { } + protected override void Update() + { + base.Update(); + + double gamePlayTimeRange = GetTimeRange(Beatmap.Difficulty.ApproachRate); + float playfieldStretch = Playfield.DrawHeight / CatchPlayfield.HEIGHT; + TimeRange.Value = gamePlayTimeRange * TimeRangeMultiplier.Value * playfieldStretch; + } + protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty); + + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchEditorPlayfieldAdjustmentContainer(); } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs index 1adc969f8fdb..a9e9e8fbd518 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs @@ -24,7 +24,7 @@ public class CatchModFlashlight : ModFlashlight public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 350; + public override float DefaultFlashlightSize => 325; protected override Flashlight CreateFlashlight() => new CatchFlashlight(this, playfield); @@ -44,7 +44,19 @@ public CatchFlashlight(CatchModFlashlight modFlashlight, CatchPlayfield playfiel : base(modFlashlight) { this.playfield = playfield; - FlashlightSize = new Vector2(0, GetSizeFor(0)); + + FlashlightSize = new Vector2(0, GetSize()); + FlashlightSmoothness = 1.4f; + } + + protected override float GetComboScaleFor(int combo) + { + if (combo >= 200) + return 0.770f; + if (combo >= 100) + return 0.885f; + + return 1.0f; } protected override void Update() @@ -54,9 +66,9 @@ protected override void Update() FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index 69ae8328e903..3f7560844ce0 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -19,17 +19,20 @@ public class CatchModRelax : ModRelax, IApplicableToDrawableRuleset @"Use the mouse to control the catcher."; - private DrawableRuleset drawableRuleset = null!; + private DrawableCatchRuleset drawableRuleset = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - this.drawableRuleset = drawableRuleset; + this.drawableRuleset = (DrawableCatchRuleset)drawableRuleset; } public void ApplyToPlayer(Player player) { if (!drawableRuleset.HasReplayLoaded.Value) - drawableRuleset.Cursor.Add(new MouseInputHelper((CatchPlayfield)drawableRuleset.Playfield)); + { + var catchPlayfield = (CatchPlayfield)drawableRuleset.Playfield; + catchPlayfield.CatcherArea.Add(new MouseInputHelper(catchPlayfield.CatcherArea)); + } } private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition @@ -38,9 +41,10 @@ private class MouseInputHelper : Drawable, IKeyBindingHandler, IReq public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public MouseInputHelper(CatchPlayfield playfield) + public MouseInputHelper(CatcherArea catcherArea) { - catcherArea = playfield.CatcherArea; + this.catcherArea = catcherArea; + RelativeSizeAxes = Axes.Both; } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index c06d9f520f5f..a73b34c9b636 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -13,6 +13,10 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public class CatchLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasPear; + + private bool hasPear => GetTexture("fruit-pear") != null; + /// /// For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. /// @@ -49,7 +53,7 @@ public override Drawable GetDrawableComponent(ISkinComponent component) switch (catchSkinComponent.Component) { case CatchSkinComponents.Fruit: - if (GetTexture("fruit-pear") != null) + if (hasPear) return new LegacyFruitPiece(); return null; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs index b2dd29841b4c..b4d29988d969 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.UI; diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs index e9c289e46acc..a5b7d8d0af4b 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK.Graphics; @@ -19,14 +20,29 @@ public class CatchComboDisplay : SkinnableDrawable { private int currentCombo; - [CanBeNull] - public ICatchComboCounter ComboCounter => Drawable as ICatchComboCounter; + public ICatchComboCounter? ComboCounter => Drawable as ICatchComboCounter; + + private readonly IBindable showCombo = new BindableBool(true); public CatchComboDisplay() : base(new CatchSkinComponent(CatchSkinComponents.CatchComboCounter), _ => Empty()) { } + [Resolved(canBeNull: true)] + private Player? player { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (player != null) + { + showCombo.BindTo(player.ShowingOverlayComponents); + showCombo.BindValueChanged(s => this.FadeTo(s.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + } + } + protected override void SkinChanged(ISkinSource skin) { base.SkinChanged(skin); diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index dad22fbe699c..ce000b0fad0a 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -23,6 +23,12 @@ public class CatchPlayfield : ScrollingPlayfield /// public const float WIDTH = 512; + /// + /// The height of the playfield. + /// This doesn't include the catcher area. + /// + public const float HEIGHT = 384; + /// /// The center position of the playfield. /// diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index ef2936ac947f..e02b91550848 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -4,6 +4,7 @@ #nullable disable using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Input; using osu.Game.Beatmaps; @@ -30,15 +31,19 @@ public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList m is ModRelax)) + KeyBindingInputManager.Add(new CatchTouchInputMapper()); } + protected double GetTimeRange(float approachRate) => IBeatmapDifficultyInfo.DifficultyRange(approachRate, 1800, 1200, 450); + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield); diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs index 6e6e83f9cff8..0e4f612999b1 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Timing; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; @@ -30,15 +31,18 @@ public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestS [Cached(typeof(IScrollingInfo))] private IScrollingInfo scrollingInfo; + [Cached] + private readonly StageDefinition stage = new StageDefinition(5); + protected ManiaPlacementBlueprintTestScene() { scrollingInfo = ((ScrollingTestContainer)HitObjectContainer).ScrollingInfo; - Add(column = new Column(0) + Add(column = new Column(0, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AccentColour = Color4.OrangeRed, + AccentColour = { Value = Color4.OrangeRed }, Clock = new FramedClock(new StopwatchClock()), // No scroll }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs index 679a15e8cbb4..4cadcf138bc8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs @@ -33,7 +33,7 @@ protected ScrollingDirection Direction protected ManiaSelectionBlueprintTestScene(int columns) { - var stageDefinitions = new List { new StageDefinition { Columns = columns } }; + var stageDefinitions = new List { new StageDefinition(columns) }; base.Content.Child = scrollingTestContainer = new ScrollingTestContainer(ScrollingDirection.Up) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs index ec9620506781..ef140995ecd4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -30,7 +30,7 @@ public class TestSceneManiaBeatSnapGrid : EditorClockTestScene private ScrollingTestContainer.TestScrollingInfo scrollingInfo = new ScrollingTestContainer.TestScrollingInfo(); [Cached(typeof(EditorBeatmap))] - private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition()) + private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition(2)) { BeatmapInfo = { @@ -56,8 +56,8 @@ public TestSceneManiaBeatSnapGrid() { Playfield = new ManiaPlayfield(new List { - new StageDefinition { Columns = 4 }, - new StageDefinition { Columns = 3 } + new StageDefinition(4), + new StageDefinition(3) }) { Clock = new FramedClock(new StopwatchClock()) diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs index e96a186ae4ea..e082b90d3b62 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs @@ -35,7 +35,7 @@ public void SetUpSteps() { AddStep("setup compose screen", () => { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 4 }) + var beatmap = new ManiaBeatmap(new StageDefinition(4)) { BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, }; diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs index fcc9e2e6c344..a3985be936ca 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs @@ -205,7 +205,7 @@ public TestComposer() { InternalChildren = new Drawable[] { - EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition(4)) { BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo } }), diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs deleted file mode 100644 index e53deb52690c..000000000000 --- a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Collections.Generic; -using osu.Game.Rulesets.Mania.Beatmaps; -using NUnit.Framework; - -namespace osu.Game.Rulesets.Mania.Tests -{ - [TestFixture] - public class ManiaColumnTypeTest - { - [TestCase(new[] - { - ColumnType.Special - }, 1)] - [TestCase(new[] - { - ColumnType.Odd, - ColumnType.Even, - ColumnType.Even, - ColumnType.Odd - }, 4)] - [TestCase(new[] - { - ColumnType.Odd, - ColumnType.Even, - ColumnType.Odd, - ColumnType.Special, - ColumnType.Odd, - ColumnType.Even, - ColumnType.Odd - }, 7)] - public void Test(IEnumerable expected, int columns) - { - var definition = new StageDefinition - { - Columns = columns - }; - var results = getResults(definition); - Assert.AreEqual(expected, results); - } - - private IEnumerable getResults(StageDefinition definition) - { - for (int i = 0; i < definition.Columns; i++) - yield return definition.GetTypeOfColumn(i); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs index eb380c07a64d..e456659ac4df 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs @@ -3,9 +3,11 @@ #nullable disable +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests @@ -37,7 +39,7 @@ public LocalKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBi { } - protected override void ReloadMappings() + protected override void ReloadMappings(IQueryable realmKeyBindings) { KeyBindings = DefaultKeyBindings; } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs index b64006316e9d..7d1a934456bc 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs @@ -18,7 +18,7 @@ public class ManiaLegacyReplayTest [TestCase(ManiaAction.Key8)] public void TestEncodeDecodeSingleStage(params ManiaAction[] actions) { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 9 }); + var beatmap = new ManiaBeatmap(new StageDefinition(9)); var frame = new ManiaReplayFrame(0, actions); var legacyFrame = frame.ToLegacy(beatmap); @@ -38,8 +38,8 @@ public void TestEncodeDecodeSingleStage(params ManiaAction[] actions) [TestCase(ManiaAction.Key8)] public void TestEncodeDecodeDualStage(params ManiaAction[] actions) { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 5 }); - beatmap.Stages.Add(new StageDefinition { Columns = 5 }); + var beatmap = new ManiaBeatmap(new StageDefinition(5)); + beatmap.Stages.Add(new StageDefinition(5)); var frame = new ManiaReplayFrame(0, actions); var legacyFrame = frame.ToLegacy(beatmap); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs new file mode 100644 index 000000000000..3bd654e75e48 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System.Collections.Generic; +using osu.Game.Rulesets.Mania.Beatmaps; +using NUnit.Framework; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaSpecialColumnTest + { + [TestCase(new[] + { + true + }, 1)] + [TestCase(new[] + { + false, + false, + false, + false + }, 4)] + [TestCase(new[] + { + false, + false, + false, + true, + false, + false, + false + }, 7)] + public void Test(IEnumerable special, int columns) + { + var definition = new StageDefinition(columns); + var results = getResults(definition); + Assert.AreEqual(special, results); + } + + private IEnumerable getResults(StageDefinition definition) + { + for (int i = 0; i < definition.Columns; i++) + yield return definition.IsSpecialColumn(i); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs new file mode 100644 index 000000000000..0e222fea8999 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModFlashlight : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.5f)] + [TestCase(3f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new ManiaModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new ManiaModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs index 7970d5b594b8..d27a79c41d92 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs @@ -85,7 +85,7 @@ private static ManiaBeatmap createModdedBeatmap() private static ManiaBeatmap createRawBeatmap() { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.ControlPointInfo.Add(0.0, new TimingControlPoint { BeatLength = 1000 }); // Set BPM to 60 // Add test hit objects diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs index 1a3513d46c93..d3e90170b297 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests.Skinning { @@ -24,15 +23,16 @@ public class ColumnTestContainer : Container [Cached] private readonly Column column; + [Cached] + private readonly StageDefinition stageDefinition = new StageDefinition(5); + public ColumnTestContainer(int column, ManiaAction action, bool showColumn = false) { InternalChildren = new[] { - this.column = new Column(column) + this.column = new Column(column, false) { Action = { Value = action }, - AccentColour = Color4.Orange, - ColumnType = column % 2 == 0 ? ColumnType.Even : ColumnType.Odd, Alpha = showColumn ? 1 : 0 }, content = new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs index fd82041ad834..75175c43d865 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs @@ -61,7 +61,6 @@ public void SetUp() => Schedule(() => c.Add(CreateHitObject().With(h => { h.HitObject.StartTime = Time.Current + 5000; - h.AccentColour.Value = Color4.Orange; })); }) }, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs index 9f235689b4a0..2c5535a65fec 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling.Algorithms; using osu.Game.Tests.Visual; @@ -24,6 +25,9 @@ public abstract class ManiaSkinnableTestScene : SkinnableTestScene [Cached(Type = typeof(IScrollingInfo))] private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo(); + [Cached] + private readonly StageDefinition stage = new StageDefinition(4); + protected override Ruleset CreateRulesetForSkinProvider() => new ManiaRuleset(); protected ManiaSkinnableTestScene() diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs index ff557638a9b8..1bfe55b07439 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs @@ -23,7 +23,7 @@ private void recreate(Func>? createBarLines = null) { var stageDefinitions = new List { - new StageDefinition { Columns = 4 }, + new StageDefinition(4), }; SetContents(_ => new ManiaPlayfield(stageDefinitions).With(s => diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs deleted file mode 100644 index bbbd7edb7bbe..000000000000 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Mania.UI.Components; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Mania.Tests.Skinning -{ - public class TestSceneKeyArea : ManiaSkinnableTestScene - { - [BackgroundDependencyLoader] - private void load() - { - SetContents(_ => new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.8f), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new ColumnTestContainer(0, ManiaAction.Key1) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) - { - RelativeSizeAxes = Axes.Both - }, - }, - new ColumnTestContainer(1, ManiaAction.Key2) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) - { - RelativeSizeAxes = Axes.Both - }, - }, - } - }); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs index 62dadbc3dd17..9817719c94e5 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs @@ -22,7 +22,7 @@ public void TestSingleStage() { stageDefinitions = new List { - new StageDefinition { Columns = 2 } + new StageDefinition(2) }; SetContents(_ => new ManiaPlayfield(stageDefinitions)); @@ -36,8 +36,8 @@ public void TestDualStages() { stageDefinitions = new List { - new StageDefinition { Columns = 2 }, - new StageDefinition { Columns = 2 } + new StageDefinition(2), + new StageDefinition(2) }; SetContents(_ => new ManiaPlayfield(stageDefinitions)); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs index f3f1b9416f5c..07aa0b845f6f 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs @@ -21,7 +21,7 @@ private void load() return new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) { - Child = new Stage(0, new StageDefinition { Columns = 4 }, ref normalAction, ref specialAction) + Child = new Stage(0, new StageDefinition(4), ref normalAction, ref specialAction) }; }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs index 687b3a747da5..0744d7e2e7d7 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Skinning; @@ -16,7 +15,7 @@ public class TestSceneStageBackground : ManiaSkinnableTestScene [BackgroundDependencyLoader] private void load() { - SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: new StageDefinition { Columns = 4 }), + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { Anchor = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs index 6cbc1727557a..979c90c80297 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Tests.Skinning @@ -15,7 +14,7 @@ public class TestSceneStageForeground : ManiaSkinnableTestScene [BackgroundDependencyLoader] private void load() { - SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: new StageDefinition { Columns = 4 }), _ => null) + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground), _ => null) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs index 21ec85bbe623..3abeb8a5f681 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs @@ -30,7 +30,7 @@ public void TestSingleNote() // | - | // | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); @@ -51,7 +51,7 @@ public void TestSingleHoldNote() // | * | // | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); @@ -70,7 +70,7 @@ public void TestSingleNoteChord() // | - | - | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); beatmap.HitObjects.Add(new Note { StartTime = 1000, Column = 1 }); @@ -92,7 +92,7 @@ public void TestHoldNoteChord() // | * | * | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000, Column = 1 }); @@ -115,7 +115,7 @@ public void TestSingleNoteStair() // | - | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); beatmap.HitObjects.Add(new Note { StartTime = 2000, Column = 1 }); @@ -142,7 +142,7 @@ public void TestHoldNoteStair() // | * | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new HoldNote { StartTime = 2000, Duration = 2000, Column = 1 }); @@ -169,7 +169,7 @@ public void TestHoldNoteWithReleasePress() // | * | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new Note { StartTime = 3000, Column = 1 }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs index 2922d1871341..83491b6fe950 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; @@ -28,6 +29,9 @@ public class TestSceneColumn : ManiaInputTestScene [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); + [Cached] + private readonly StageDefinition stage = new StageDefinition(1); + private readonly List columns = new List(); public TestSceneColumn() @@ -84,12 +88,12 @@ private void createHoldNote() private Drawable createColumn(ScrollingDirection direction, ManiaAction action, int index) { - var column = new Column(index) + var column = new Column(index, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Height = 0.85f, - AccentColour = Color4.OrangeRed, + AccentColour = { Value = Color4.OrangeRed }, Action = { Value = action }, }; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs index 223f8dae444e..d273f5cb35d8 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs @@ -4,11 +4,13 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; @@ -24,6 +26,9 @@ public class TestSceneDrawableManiaHitObject : OsuTestScene private Column column; + [Cached] + private readonly StageDefinition stage = new StageDefinition(1); + [SetUp] public void SetUp() => Schedule(() => { @@ -35,11 +40,11 @@ public void SetUp() => Schedule(() => RelativeSizeAxes = Axes.Y, TimeRange = 2000, Clock = new FramedClock(clock), - Child = column = new Column(0) + Child = column = new Column(0, false) { Action = { Value = ManiaAction.Key1 }, Height = 0.85f, - AccentColour = Color4.Gray + AccentColour = { Value = Color4.Gray }, }, }; }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 8f776ff5079f..0296303867d0 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -49,7 +49,7 @@ public void TestNoInput() assertHeadJudgement(HitResult.Miss); assertTickJudgement(HitResult.LargeTickMiss); assertTailJudgement(HitResult.Miss); - assertNoteJudgement(HitResult.IgnoreHit); + assertNoteJudgement(HitResult.IgnoreMiss); } /// diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs index a563dc3106e6..464dbecee5e7 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs @@ -76,8 +76,8 @@ public void TestHoldNoteMissAfterNextObjectStartTime() performTest(objects, new List()); - addJudgementAssert(objects[0], HitResult.IgnoreHit); - addJudgementAssert(objects[1], HitResult.IgnoreHit); + addJudgementAssert(objects[0], HitResult.IgnoreMiss); + addJudgementAssert(objects[1], HitResult.IgnoreMiss); } [Test] @@ -141,7 +141,7 @@ private void performTest(List hitObjects, List fram { AddStep("load player", () => { - Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition(4)) { HitObjects = hitObjects, BeatmapInfo = diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs index cf8947c1edf2..6387dac957a9 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs @@ -135,7 +135,7 @@ private ScrollingTestContainer createStage(ScrollingDirection direction, ManiaAc { var specialAction = ManiaAction.Special1; - var stage = new Stage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); + var stage = new Stage(0, new StageDefinition(2), ref action, ref specialAction); stages.Add(stage); return new ScrollingTestContainer(direction) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs index e84d02775aa2..9f2e3d2502a9 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs @@ -85,7 +85,7 @@ private ManiaBeatmap createTestBeatmap() { const double beat_length = 500; - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }) + var beatmap = new ManiaBeatmap(new StageDefinition(1)) { HitObjects = { diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index 29510765910e..0d7b03d830d1 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -1,9 +1,9 @@  - + - + WinExe diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs b/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs deleted file mode 100644 index 0114987e3c5e..000000000000 --- a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -namespace osu.Game.Rulesets.Mania.Beatmaps -{ - public enum ColumnType - { - Even, - Odd, - Special - } -} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index 4879ce6748b6..b5655a457964 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; @@ -60,5 +61,18 @@ public override IEnumerable GetStatistics() }, }; } + + public StageDefinition GetStageForColumnIndex(int column) + { + foreach (var stage in Stages) + { + if (column < stage.Columns) + return stage; + + column -= stage.Columns; + } + + throw new ArgumentOutOfRangeException(nameof(column), "Provided index exceeds all available stages"); + } } } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 90cd7f57b576..632b7cdcc715 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -93,10 +93,10 @@ protected override Beatmap ConvertBeatmap(IBeatmap original, Can protected override Beatmap CreateBeatmap() { - beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }, originalTargetColumns); + beatmap = new ManiaBeatmap(new StageDefinition(TargetColumns), originalTargetColumns); if (Dual) - beatmap.Stages.Add(new StageDefinition { Columns = TargetColumns }); + beatmap.Stages.Add(new StageDefinition(TargetColumns)); return beatmap; } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs index 54e2d4686fee..898b558eb33c 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs @@ -11,32 +11,26 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// /// Defines properties for each stage in a . /// - public struct StageDefinition + public class StageDefinition { /// /// The number of s which this stage contains. /// - public int Columns; + public readonly int Columns; + + public StageDefinition(int columns) + { + if (columns < 1) + throw new ArgumentException("Column count must be above zero.", nameof(columns)); + + Columns = columns; + } /// /// Whether the column index is a special column for this stage. /// /// The 0-based column index. /// Whether the column is a special column. - public readonly bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; - - /// - /// Get the type of column given a column index. - /// - /// The 0-based column index. - /// The type of the column. - public readonly ColumnType GetTypeOfColumn(int column) - { - if (IsSpecialColumn(column)) - return ColumnType.Special; - - int distanceToEdge = Math.Min(column, (Columns - 1) - column); - return distanceToEdge % 2 == 0 ? ColumnType.Odd : ColumnType.Even; - } + public bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index a925e7c0acf6..440dec82af34 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -38,7 +38,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - scoreAccuracy = customAccuracy; + scoreAccuracy = calculateCustomAccuracy(); // Arbitrary initial value for scaling pp in order to standardize distributions across game modes. // The specific number has no intrinsic meaning and can be adjusted as needed. @@ -73,6 +73,12 @@ private double computeDifficultyValue(ManiaDifficultyAttributes attributes) /// /// Accuracy used to weight judgements independently from the score's actual accuracy. /// - private double customAccuracy => (countPerfect * 320 + countGreat * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 320); + private double calculateCustomAccuracy() + { + if (totalHits == 0) + return 0; + + return (countPerfect * 320 + countGreat * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 320); + } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index ad75afff8e31..f438d6497cb6 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -33,5 +33,7 @@ public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObjec } protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + + protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 061dedb07ad4..6162184c9a8f 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -26,6 +26,8 @@ using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.Skinning.Argon; +using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Legacy; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; @@ -66,6 +68,15 @@ public class ManiaRuleset : Ruleset, ILegacyRuleset { switch (skin) { + case TrianglesSkin: + return new ManiaTrianglesSkinTransformer(skin, beatmap); + + case ArgonSkin: + return new ManiaArgonSkinTransformer(skin, beatmap); + + case DefaultLegacySkin: + return new ManiaClassicSkinTransformer(skin, beatmap); + case LegacySkin: return new ManiaLegacySkinTransformer(skin, beatmap); } diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs index 21b362df00fa..f05edb467755 100644 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs @@ -3,29 +3,19 @@ #nullable disable -using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.UI; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania { public class ManiaSkinComponent : GameplaySkinComponent { - /// - /// The intended for this component. - /// May be null if the component is not a direct member of a . - /// - public readonly StageDefinition? StageDefinition; - /// /// Creates a new . /// /// The component. - /// The intended for this component. May be null if the component is not a direct member of a . - public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null) + public ManiaSkinComponent(ManiaSkinComponents component) : base(component) { - StageDefinition = stageDefinition; } protected override string RulesetPrefix => ManiaRuleset.SHORT_NAME; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs index 6eaede211225..947915cdf9b4 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs @@ -36,7 +36,7 @@ private class ManiaFlashlight : Flashlight public ManiaFlashlight(ManiaModFlashlight modFlashlight) : base(modFlashlight) { - FlashlightSize = new Vector2(DrawWidth, GetSizeFor(0)); + FlashlightSize = new Vector2(DrawWidth, GetSize()); AddLayout(flashlightProperties); } @@ -54,9 +54,9 @@ protected override void Update() } } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(DrawWidth, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(DrawWidth, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "RectangularFlashlight"; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs index 602034893801..a607ed572ddb 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs @@ -54,10 +54,6 @@ public DrawableBarLine(BarLine barLine) } } - protected override void UpdateInitialTransforms() - { - } - protected override void UpdateStartTimeStateTransforms() => this.FadeOut(150); } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 19792086a799..14dbc432ff3d 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -4,12 +4,14 @@ #nullable disable using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Audio; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -38,6 +40,8 @@ public class DrawableHoldNote : DrawableManiaHitObject, IKeyBindingHan private Container tailContainer; private Container tickContainer; + private PausableSkinnableSound slidingSample; + /// /// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed. /// @@ -65,6 +69,8 @@ public class DrawableHoldNote : DrawableManiaHitObject, IKeyBindingHan /// private double? releaseTime; + public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset; + public DrawableHoldNote() : this(null) { @@ -108,6 +114,7 @@ private void load() }, tickContainer = new Container { RelativeSizeAxes = Axes.Both }, tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + slidingSample = new PausableSkinnableSound { Looping = true } }); maskedContents.AddRange(new[] @@ -118,6 +125,13 @@ private void load() }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + isHitting.BindValueChanged(updateSlidingSample, true); + } + protected override void OnApply() { base.OnApply(); @@ -248,7 +262,7 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) tick.MissForcefully(); } - ApplyResult(r => r.Type = r.Judgement.MaxResult); + ApplyResult(r => r.Type = Tail.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); endHold(); } @@ -322,5 +336,38 @@ private void endHold() HoldStartTime = null; isHitting.Value = false; } + + protected override void LoadSamples() + { + // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. + + if (HitObject.SampleControlPoint == null) + { + throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." + + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); + } + + slidingSample.Samples = HitObject.CreateSlidingSamples().Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + } + + public override void StopAllSamples() + { + base.StopAllSamples(); + slidingSample?.Stop(); + } + + private void updateSlidingSample(ValueChangedEvent tracking) + { + if (tracking.NewValue) + slidingSample?.Play(); + else + slidingSample?.Stop(); + } + + protected override void OnFree() + { + slidingSample.Samples = null; + base.OnFree(); + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs index d374e935ec8d..ac646ea427d3 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -30,20 +30,15 @@ public DrawableHoldNoteHead(HeadNote headNote) public bool UpdateResult() => base.UpdateResult(true); - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - // This hitobject should never expire, so this is just a safe maximum. - LifetimeEnd = LifetimeStart + 30000; - } - protected override void UpdateHitStateTransforms(ArmedState state) { // suppress the base call explicitly. // the hold note head should never change its visual state on its own due to the "freezing" mechanic // (when hit, it remains visible in place at the judgement line; when dropped, it will scroll past the line). // it will be hidden along with its parenting hold note when required. + + // Set `LifetimeEnd` explicitly to a non-`double.MaxValue` because otherwise this DHO is automatically expired. + LifetimeEnd = double.PositiveInfinity; } public override bool OnPressed(KeyBindingPressEvent e) => false; // Handled by the hold note diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index bcc10ab7bcbc..73dc937a00a9 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -23,10 +23,6 @@ public abstract class DrawableManiaHitObject : DrawableHitObject protected readonly IBindable Direction = new Bindable(); - // Leaving the default (10s) makes hitobjects not appear, as this offset is used for the initial state transforms. - // Calculated as DrawableManiaRuleset.MAX_TIME_RANGE + some additional allowance for velocity < 1. - protected override double InitialLifetimeOffset => 30000; - [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } @@ -69,22 +65,6 @@ protected override void LoadComplete() Direction.BindValueChanged(OnDirectionChanged, true); } - protected override void OnApply() - { - base.OnApply(); - - if (ParentHitObject != null) - AccentColour.BindTo(ParentHitObject.AccentColour); - } - - protected override void OnFree() - { - base.OnFree(); - - if (ParentHitObject != null) - AccentColour.UnbindFrom(ParentHitObject.AccentColour); - } - protected virtual void OnDirectionChanged(ValueChangedEvent e) { Anchor = Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs similarity index 50% rename from osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs rename to osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs index 5bd2d3ab48a0..598a765d3cf4 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs @@ -1,8 +1,6 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -12,26 +10,38 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK.Graphics; -namespace osu.Game.Rulesets.Mania.UI.Components +namespace osu.Game.Rulesets.Mania.Skinning.Argon { - public class ColumnBackground : CompositeDrawable, IKeyBindingHandler, IHasAccentColour + public class ArgonColumnBackground : CompositeDrawable, IKeyBindingHandler { - private readonly IBindable action = new Bindable(); + private readonly IBindable direction = new Bindable(); - private Box background; - private Box backgroundOverlay; + private Color4 brightColour; + private Color4 dimColour; - private readonly IBindable direction = new Bindable(); + private Box background = null!; + private Box backgroundOverlay = null!; - [BackgroundDependencyLoader] - private void load(IBindable action, IScrollingInfo scrollingInfo) + [Resolved] + private Column column { get; set; } = null!; + + private Bindable accentColour = null!; + + public ArgonColumnBackground() { - this.action.BindTo(action); + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { InternalChildren = new[] { background = new Box @@ -49,61 +59,42 @@ private void load(IBindable action, IScrollingInfo scrollingInfo) } }; - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => { - backgroundOverlay.Anchor = backgroundOverlay.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - updateColours(); + background.Colour = colour.NewValue.Darken(3).Opacity(0.8f); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); }, true); - } - protected override void LoadComplete() - { - base.LoadComplete(); - updateColours(); + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); } - private Color4 accentColour; - - public Color4 AccentColour + private void onDirectionChanged(ValueChangedEvent direction) { - get => accentColour; - set + if (direction.NewValue == ScrollingDirection.Up) { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.TopLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(brightColour, dimColour); + } + else + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.BottomLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(dimColour, brightColour); } - } - - private void updateColours() - { - if (!IsLoaded) - return; - - background.Colour = AccentColour.Darken(5); - - var brightPoint = AccentColour.Opacity(0.6f); - var dimPoint = AccentColour.Opacity(0); - - backgroundOverlay.Colour = ColourInfo.GradientVertical( - direction.Value == ScrollingDirection.Up ? brightPoint : dimPoint, - direction.Value == ScrollingDirection.Up ? dimPoint : brightPoint); } public bool OnPressed(KeyBindingPressEvent e) { - if (e.Action == action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); return false; } public void OnReleased(KeyBindingReleaseEvent e) { - if (e.Action == action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs new file mode 100644 index 000000000000..af179d5580fa --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.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.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonHitExplosion : CompositeDrawable, IHitExplosion + { + public override bool RemoveWhenNotAlive => true; + + [Resolved] + private Column column { get; set; } = null!; + + private readonly IBindable direction = new Bindable(); + + private Container largeFaint = null!; + + private Bindable accentColour = null!; + + public ArgonHitExplosion() + { + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new Drawable[] + { + largeFaint = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Blending = BlendingParameters.Additive, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.Colour = Interpolation.ValueAt(0.8f, colour.NewValue, Color4.White, 0, 1); + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour.NewValue, + Roundness = 40, + Radius = 60, + }; + }, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + Anchor = Anchor.TopCentre; + Y = ArgonNotePiece.NOTE_HEIGHT / 2; + } + else + { + Anchor = Anchor.BottomCentre; + Y = -ArgonNotePiece.NOTE_HEIGHT / 2; + } + } + + public void Animate(JudgementResult result) + { + this.FadeOutFromOne(PoolableHitExplosion.DURATION, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs new file mode 100644 index 000000000000..9e449623d5a8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs @@ -0,0 +1,47 @@ +// 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.Graphics.Shapes; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonHitTarget : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + + InternalChildren = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.3f, + Blending = BlendingParameters.Additive, + Colour = Color4.White + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Anchor = Origin = direction.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs new file mode 100644 index 000000000000..757190c4ae51 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + /// + /// Represents length-wise portion of a hold note. + /// + public class ArgonHoldBodyPiece : CompositeDrawable, IHoldNoteBody + { + protected readonly Bindable AccentColour = new Bindable(); + protected readonly IBindable IsHitting = new Bindable(); + + private Drawable background = null!; + private Box foreground = null!; + + public ArgonHoldBodyPiece() + { + RelativeSizeAxes = Axes.Both; + + // Without this, the width of the body will be slightly larger than the head/tail. + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + InternalChildren = new[] + { + background = new Box { RelativeSizeAxes = Axes.Both }, + foreground = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }; + + if (drawableObject != null) + { + var holdNote = (DrawableHoldNote)drawableObject; + + AccentColour.BindTo(holdNote.AccentColour); + IsHitting.BindTo(holdNote.IsHitting); + } + + AccentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(1.2f); + foreground.Colour = colour.NewValue.Opacity(0.2f); + }, true); + + IsHitting.BindValueChanged(hitting => + { + const float animation_length = 50; + + foreground.ClearTransforms(); + + if (hitting.NewValue) + { + // wait for the next sync point + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + + using (foreground.BeginDelayedSequence(synchronisedOffset)) + { + foreground.FadeTo(1, animation_length).Then() + .FadeTo(0.5f, animation_length) + .Loop(); + } + } + else + { + foreground.FadeOut(animation_length); + } + }); + } + + public void Recycle() + { + foreground.ClearTransforms(); + foreground.Alpha = 0; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs new file mode 100644 index 000000000000..e1068c6cd8ec --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs @@ -0,0 +1,91 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + internal class ArgonHoldNoteTailPiece : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + private readonly Box shadow; + + public ArgonHoldNoteTailPiece() + { + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + shadow = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.82f, + Masking = true, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Circle + { + RelativeSizeAxes = Axes.X, + Height = ArgonNotePiece.CORNER_RADIUS * 2, + }, + }; + } + + [BackgroundDependencyLoader(true)] + private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue, + accent.NewValue.Darken(0.1f) + ); + + shadow.Colour = accent.NewValue.Darken(0.5f); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 000000000000..e7dfec256d4b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,193 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonJudgementPiece : CompositeDrawable, IAnimatableJudgement + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } = null!; + + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + { + Result = result; + Origin = Anchor.Centre; + Y = 160; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + JudgementText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = Result.GetDescription().ToUpperInvariant(), + Colour = colours.ForHitResult(Result), + Blending = BlendingParameters.Additive, + Spacing = new Vector2(10, 0), + Font = OsuFont.Default.With(size: 28, weight: FontWeight.Regular), + }, + }; + + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + }); + } + } + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + switch (Result) + { + default: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.4f), 1800, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + break; + } + + this.FadeOutFromOne(800); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private class RingExplosion : CompositeDrawable + { + private readonly float travel = 52; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.3f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs new file mode 100644 index 000000000000..7670c9bdf237 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs @@ -0,0 +1,272 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonKeyArea : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer = null!; + private Drawable background = null!; + + private Circle hitTargetLine = null!; + + private Container bottomIcon = null!; + private CircularContainer topIcon = null!; + + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + public ArgonKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + const float icon_circle_size = 8; + const float icon_spacing = 7; + const float icon_vertical_offset = -30; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + // Ensure the area is tall enough to put the target line in the correct location. + // This is to also allow the main background component to overlap the target line + // and avoid an inner corner radius being shown below the target line. + Height = Stage.HIT_TARGET_POSITION + ArgonNotePiece.CORNER_RADIUS * 2, + Children = new[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Child = background = new Box + { + Name = "Key gradient", + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + hitTargetLine = new Circle + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = OsuColour.Gray(196 / 255f), + Height = ArgonNotePiece.CORNER_RADIUS * 2, + Masking = true, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Container + { + Name = "Icons", + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + bottomIcon = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Y = icon_vertical_offset, + Children = new[] + { + new Circle + { + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = -icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + } + }, + topIcon = new CircularContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -icon_vertical_offset, + Size = new Vector2(22, 14), + Masking = true, + BorderThickness = 4, + BorderColour = Color4.White, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + } + } + }, + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.2f); + bottomIcon.Colour = colour.NewValue; + }, + true); + + // Yes, proxy everything. + column.TopLevelContainer.Add(CreateProxy()); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + switch (direction.NewValue) + { + case ScrollingDirection.Up: + directionContainer.Scale = new Vector2(1, -1); + directionContainer.Anchor = Anchor.TopLeft; + directionContainer.Origin = Anchor.BottomLeft; + break; + + case ScrollingDirection.Down: + directionContainer.Scale = new Vector2(1, 1); + directionContainer.Anchor = Anchor.BottomLeft; + directionContainer.Origin = Anchor.BottomLeft; + break; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != column.Action.Value) return false; + + const double lighting_fade_in_duration = 70; + Color4 lightingColour = getLightingColour(); + + background + .FlashColour(accentColour.Value.Lighten(0.8f), 200, Easing.OutQuint) + .FadeTo(1, lighting_fade_in_duration, Easing.OutQuint) + .Then() + .FadeTo(0.8f, 500); + + hitTargetLine.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.4f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + topIcon.ScaleTo(0.9f, lighting_fade_in_duration, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.1f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + bottomIcon.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.2f), + Radius = 60, + }, lighting_fade_in_duration, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action != column.Action.Value) return; + + const double lighting_fade_out_duration = 800; + + Color4 lightingColour = getLightingColour().Opacity(0); + + // background fades out faster than lighting elements to give better definition to the player. + background.FadeTo(0.3f, 50, Easing.OutQuint) + .Then() + .FadeOut(lighting_fade_out_duration, Easing.OutQuint); + + topIcon.ScaleTo(1f, 200, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 20, + }, lighting_fade_out_duration, Easing.OutQuint); + + hitTargetLine.FadeColour(OsuColour.Gray(196 / 255f), lighting_fade_out_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 25, + }, lighting_fade_out_duration, Easing.OutQuint); + + bottomIcon.FadeColour(accentColour.Value, lighting_fade_out_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 30, + }, lighting_fade_out_duration, Easing.OutQuint); + } + } + + private Color4 getLightingColour() => Interpolation.ValueAt(0.2f, accentColour.Value, Color4.White, 0, 1); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs new file mode 100644 index 000000000000..454a6b012b6f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs @@ -0,0 +1,110 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + internal class ArgonNotePiece : CompositeDrawable + { + public const float NOTE_HEIGHT = 42; + + public const float CORNER_RADIUS = 3.4f; + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + private readonly Box shadow; + + public ArgonNotePiece() + { + RelativeSizeAxes = Axes.X; + Height = NOTE_HEIGHT; + + CornerRadius = CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + shadow = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = 0.82f, + Masking = true, + CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Circle + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = CORNER_RADIUS * 2, + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 4, + Icon = FontAwesome.Solid.AngleDown, + Size = new Vector2(20), + Scale = new Vector2(1, 0.7f) + } + }; + } + + [BackgroundDependencyLoader(true)] + private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue.Lighten(0.1f), + accent.NewValue + ); + + shadow.Colour = accent.NewValue.Darken(0.5f); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs new file mode 100644 index 000000000000..1881695b141e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs @@ -0,0 +1,16 @@ +// 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; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ArgonStageBackground : CompositeDrawable + { + public ArgonStageBackground() + { + RelativeSizeAxes = Axes.Both; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs new file mode 100644 index 000000000000..ae313e0b91a8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -0,0 +1,141 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ManiaArgonSkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + + public ManiaArgonSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + } + + public override Drawable? GetDrawableComponent(ISkinComponent component) + { + switch (component) + { + case GameplaySkinComponent resultComponent: + return new ArgonJudgementPiece(resultComponent.Component); + + case ManiaSkinComponent maniaComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (maniaComponent.Component) + { + case ManiaSkinComponents.StageBackground: + return new ArgonStageBackground(); + + case ManiaSkinComponents.ColumnBackground: + return new ArgonColumnBackground(); + + case ManiaSkinComponents.HoldNoteBody: + return new ArgonHoldBodyPiece(); + + case ManiaSkinComponents.HoldNoteTail: + return new ArgonHoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteHead: + case ManiaSkinComponents.Note: + return new ArgonNotePiece(); + + case ManiaSkinComponents.HitTarget: + return new ArgonHitTarget(); + + case ManiaSkinComponents.KeyArea: + return new ArgonKeyArea(); + + case ManiaSkinComponents.HitExplosion: + return new ArgonHitExplosion(); + } + + break; + } + + return base.GetDrawableComponent(component); + } + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + int column = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(column); + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnSpacing: + return SkinUtils.As(new Bindable(2)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: + case LegacyManiaSkinConfigurationLookups.StagePaddingTop: + return SkinUtils.As(new Bindable(30)); + + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + return SkinUtils.As(new Bindable( + stage.IsSpecialColumn(column) ? 120 : 60 + )); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + + Color4 colour; + + const int total_colours = 7; + + if (stage.IsSpecialColumn(column)) + colour = new Color4(159, 101, 255, 255); + else + { + switch (column % total_colours) + { + case 0: + colour = new Color4(240, 216, 0, 255); + break; + + case 1: + colour = new Color4(240, 101, 0, 255); + break; + + case 2: + colour = new Color4(240, 0, 130, 255); + break; + + case 3: + colour = new Color4(192, 0, 240, 255); + break; + + case 4: + colour = new Color4(0, 96, 240, 255); + break; + + case 5: + colour = new Color4(0, 226, 240, 255); + break; + + case 6: + colour = new Color4(0, 240, 96, 255); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + return SkinUtils.As(new Bindable(colour)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs new file mode 100644 index 000000000000..eb51179cea22 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs @@ -0,0 +1,49 @@ +// 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.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Default +{ + public class ManiaTrianglesSkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + + public ManiaTrianglesSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + } + + private readonly Color4 colourEven = new Color4(6, 84, 0, 255); + private readonly Color4 colourOdd = new Color4(94, 0, 57, 255); + private readonly Color4 colourSpecial = new Color4(0, 48, 63, 255); + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + int column = maniaLookup.ColumnIndex ?? 0; + + var stage = beatmap.GetStageForColumnIndex(column); + + if (stage.IsSpecialColumn(column)) + return SkinUtils.As(new Bindable(colourSpecial)); + + int distanceToEdge = Math.Min(column, (stage.Columns - 1) - column); + return SkinUtils.As(new Bindable(distanceToEdge % 2 == 0 ? colourOdd : colourEven)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs index ab953ccfb9cc..e227c808450f 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -20,6 +21,9 @@ public class LegacyManiaColumnElement : CompositeDrawable [Resolved] protected Column Column { get; private set; } + [Resolved] + private StageDefinition stage { get; set; } + /// /// The column type identifier to use for texture lookups, in the case of no user-provided configuration. /// @@ -28,19 +32,12 @@ public class LegacyManiaColumnElement : CompositeDrawable [BackgroundDependencyLoader] private void load() { - switch (Column.ColumnType) + if (Column.IsSpecial) + FallbackColumnIndex = "S"; + else { - case ColumnType.Special: - FallbackColumnIndex = "S"; - break; - - case ColumnType.Odd: - FallbackColumnIndex = "1"; - break; - - case ColumnType.Even: - FallbackColumnIndex = "2"; - break; + int distanceToEdge = Math.Min(Column.Index, (stage.Columns - 1) - Column.Index); + FallbackColumnIndex = distanceToEdge % 2 == 0 ? "1" : "2"; } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs index 740ccbfe27f6..d039551cd721 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs @@ -18,20 +18,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class LegacyStageBackground : CompositeDrawable { - private readonly StageDefinition stageDefinition; - private Drawable leftSprite; private Drawable rightSprite; private ColumnFlow columnBackgrounds; - public LegacyStageBackground(StageDefinition stageDefinition) + public LegacyStageBackground() { - this.stageDefinition = stageDefinition; RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(ISkinSource skin, StageDefinition stageDefinition) { string leftImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LeftStageImage)?.Value ?? "mania-stage-left"; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs new file mode 100644 index 000000000000..e57927897cad --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Legacy +{ + public class ManiaClassicSkinTransformer : ManiaLegacySkinTransformer + { + public ManiaClassicSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin, beatmap) + { + } + + public override IBindable GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + var baseLookup = base.GetConfig(lookup); + + if (baseLookup != null) + return baseLookup; + + // default provisioning. + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + return SkinUtils.As(new Bindable(Color4.Black)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index dd5baa81506e..a07dbea3688e 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class ManiaLegacySkinTransformer : LegacySkinTransformer { - private readonly ManiaBeatmap beatmap; + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasKeyTexture.Value; /// /// Mapping of to their corresponding @@ -60,6 +59,8 @@ private static readonly IReadOnlyDictionary default_hit_resul /// private readonly Lazy hasKeyTexture; + private readonly ManiaBeatmap beatmap; + public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap) : base(skin) { @@ -113,8 +114,7 @@ public override Drawable GetDrawableComponent(ISkinComponent component) return new LegacyHitExplosion(); case ManiaSkinComponents.StageBackground: - Debug.Assert(maniaComponent.StageDefinition != null); - return new LegacyStageBackground(maniaComponent.StageDefinition.Value); + return new LegacyStageBackground(); case ManiaSkinComponents.StageForeground: return new LegacyStageForeground(); @@ -151,7 +151,9 @@ public override ISample GetSample(ISampleInfo sampleInfo) public override IBindable GetConfig(TLookup lookup) { if (lookup is ManiaSkinConfigurationLookup maniaLookup) - return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn)); + { + return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.ColumnIndex)); + } return base.GetConfig(lookup); } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs index 4d0c32111663..e22bf630496d 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs @@ -15,9 +15,9 @@ public static class ManiaSkinConfigExtensions /// /// The skin from which configuration is retrieved. /// The value to retrieve. - /// If not null, denotes the index of the column to which the entry applies. - public static IBindable GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? index = null) + /// If not null, denotes the index of the column to which the entry applies. + public static IBindable GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) => skin.GetConfig( - new ManiaSkinConfigurationLookup(lookup, index)); + new ManiaSkinConfigurationLookup(lookup, columnIndex)); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs index e9005a3da047..59188f02f95e 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs @@ -16,20 +16,21 @@ public class ManiaSkinConfigurationLookup public readonly LegacyManiaSkinConfigurationLookups Lookup; /// - /// The intended index for the configuration. + /// The column which is being looked up. /// May be null if the configuration does not apply to a . + /// Note that this is the absolute index across all stages. /// - public readonly int? TargetColumn; + public readonly int? ColumnIndex; /// /// Creates a new . /// /// The lookup value. - /// The intended index for the configuration. May be null if the configuration does not apply to a . - public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + /// The intended index for the configuration. May be null if the configuration does not apply to a . + public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) { Lookup = lookup; - TargetColumn = targetColumn; + ColumnIndex = columnIndex; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index deb1b155b56c..3d46bdaa7b30 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -3,30 +3,30 @@ #nullable disable -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Platform; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; -using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.UI; +using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { [Cached] - public class Column : ScrollingPlayfield, IKeyBindingHandler, IHasAccentColour + public class Column : ScrollingPlayfield, IKeyBindingHandler { public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; @@ -39,23 +39,46 @@ public class Column : ScrollingPlayfield, IKeyBindingHandler, IHasA public readonly Bindable Action = new Bindable(); public readonly ColumnHitObjectArea HitObjectArea; - internal readonly Container TopLevelContainer; - private readonly DrawablePool hitExplosionPool; + internal readonly Container TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }; + private DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; public Container UnderlayElements => HitObjectArea.UnderlayElements; - private readonly GameplaySampleTriggerSource sampleTriggerSource; + private GameplaySampleTriggerSource sampleTriggerSource; + + /// + /// Whether this is a special (ie. scratch) column. + /// + public readonly bool IsSpecial; + + public readonly Bindable AccentColour = new Bindable(Color4.Black); - public Column(int index) + public Column(int index, bool isSpecial) { Index = index; + IsSpecial = isSpecial; RelativeSizeAxes = Axes.Y; Width = COLUMN_WIDTH; + hitPolicy = new OrderedHitPolicy(HitObjectContainer); + HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }; + } + + [Resolved] + private ISkinSource skin { get; set; } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + SkinnableDrawable keyArea; + + skin.SourceChanged += onSourceChanged; + onSourceChanged(); + Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, }; InternalChildren = new[] @@ -64,17 +87,18 @@ public Column(int index) sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), - HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) + HitObjectArea, + keyArea = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, }, background, - TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }, + TopLevelContainer, new ColumnTouchInputArea(this) }; - hitPolicy = new OrderedHitPolicy(HitObjectContainer); + applyGameWideClock(background); + applyGameWideClock(keyArea); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); @@ -83,20 +107,38 @@ public Column(int index) RegisterPool(10, 50); RegisterPool(10, 50); RegisterPool(50, 250); + + // Some elements don't handle rewind correctly and fixing them is non-trivial. + // In the future we need a better solution to this, but as a temporary work-around, give these components the game-wide + // clock so they don't need to worry about rewind. + // This only works because they handle OnPressed/OnReleased which results in a correct state while rewinding. + // + // This is kinda dodgy (and will cause weirdness when pausing gameplay) but is better than completely broken rewind. + void applyGameWideClock(Drawable drawable) + { + drawable.Clock = host.UpdateThread.Clock; + drawable.ProcessCustomClock = false; + } + } + + private void onSourceChanged() + { + AccentColour.Value = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, Index)?.Value ?? Color4.Black; } protected override void LoadComplete() { base.LoadComplete(); - NewResult += OnNewResult; } - public ColumnType ColumnType { get; set; } - - public bool IsSpecial => ColumnType == ColumnType.Special; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); - public Color4 AccentColour { get; set; } + if (skin != null) + skin.SourceChanged -= onSourceChanged; + } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -111,7 +153,7 @@ protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObje DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject; - maniaObject.AccentColour.Value = AccentColour; + maniaObject.AccentColour.BindTo(AccentColour); maniaObject.CheckHittable = hitPolicy.IsHittable; } diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index 871ec9f1a396..9b3f6d70330a 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -36,6 +36,8 @@ public ColumnFlow(StageDefinition stageDefinition) AutoSizeAxes = Axes.X; + Masking = true; + InternalChild = columns = new FillFlowContainer { RelativeSizeAxes = Axes.Y, diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs index 39d17db6bef2..3680e7ea0a06 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs @@ -30,6 +30,8 @@ public class DefaultColumnBackground : CompositeDrawable, IKeyBindingHandler accentColour; + public DefaultColumnBackground() { RelativeSizeAxes = Axes.Both; @@ -55,9 +57,13 @@ private void load(IScrollingInfo scrollingInfo) } }; - background.Colour = column.AccentColour.Darken(5); - brightColour = column.AccentColour.Opacity(0.6f); - dimColour = column.AccentColour.Opacity(0); + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(5); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); + }, true); direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs index 53fa86125fe6..97aa89778291 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs @@ -25,6 +25,8 @@ public class DefaultHitTarget : CompositeDrawable private Container hitTargetLine; private Drawable hitTargetBar; + private Bindable accentColour; + [Resolved] private Column column { get; set; } @@ -54,12 +56,16 @@ private void load(IScrollingInfo scrollingInfo) }, }; - hitTargetLine.EdgeEffect = new EdgeEffectParameters + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = column.AccentColour.Opacity(0.5f), - }; + hitTargetLine.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = colour.NewValue.Opacity(0.5f), + }; + }, true); direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs index 5a0fab2ff4f8..600c9feb73bd 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs @@ -30,6 +30,8 @@ public class DefaultKeyArea : CompositeDrawable, IKeyBindingHandler private Container keyIcon; private Drawable gradient; + private Bindable accentColour; + [Resolved] private Column column { get; set; } @@ -75,15 +77,19 @@ private void load(IScrollingInfo scrollingInfo) } }; - keyIcon.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = column.AccentColour.Opacity(0.5f), - }; - direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + keyIcon.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = colour.NewValue.Opacity(0.5f), + }; + }, true); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs index e83cd10d2dd0..59716ee3e26d 100644 --- a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs @@ -32,6 +32,10 @@ public class DefaultHitExplosion : CompositeDrawable, IHitExplosion private CircularContainer largeFaint; private CircularContainer mainGlow1; + private CircularContainer mainGlow2; + private CircularContainer mainGlow3; + + private Bindable accentColour; public DefaultHitExplosion() { @@ -48,8 +52,6 @@ private void load(IScrollingInfo scrollingInfo) const float roundness = 80; const float initial_height = 10; - var colour = Interpolation.ValueAt(0.4f, column.AccentColour, Color4.White, 0, 1); - InternalChildren = new Drawable[] { largeFaint = new CircularContainer @@ -61,13 +63,6 @@ private void load(IScrollingInfo scrollingInfo) // we want our size to be very small so the glow dominates it. Size = new Vector2(default_large_faint_size), Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, column.AccentColour, Color4.White, 0, 1).Opacity(0.3f), - Roundness = 160, - Radius = 200, - }, }, mainGlow1 = new CircularContainer { @@ -76,15 +71,8 @@ private void load(IScrollingInfo scrollingInfo) RelativeSizeAxes = Axes.Both, Masking = true, Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.6f, column.AccentColour, Color4.White, 0, 1), - Roundness = 20, - Radius = 50, - }, }, - new CircularContainer + mainGlow2 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -93,15 +81,8 @@ private void load(IScrollingInfo scrollingInfo) Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, Rotation = RNG.NextSingle(-angle_variance, angle_variance), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, }, - new CircularContainer + mainGlow3 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -110,18 +91,44 @@ private void load(IScrollingInfo scrollingInfo) Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, Rotation = RNG.NextSingle(-angle_variance, angle_variance), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, } }; direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, colour.NewValue, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }; + mainGlow1.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, colour.NewValue, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }; + mainGlow2.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, colour.NewValue, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + mainGlow3.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, colour.NewValue, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + }, true); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs index 28509d1f4e97..a7b94f9f22f7 100644 --- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -42,6 +42,8 @@ protected override void PrepareForUse() { base.PrepareForUse(); + LifetimeStart = Time.Current; + (skinnableExplosion?.Drawable as IHitExplosion)?.Animate(Result); this.Delay(DURATION).Then().Expire(); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index c578bbb703a7..1273cb3d3228 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -12,6 +13,7 @@ using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -19,7 +21,6 @@ using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { @@ -28,6 +29,9 @@ namespace osu.Game.Rulesets.Mania.UI /// public class Stage : ScrollingPlayfield { + [Cached] + public readonly StageDefinition Definition; + public const float COLUMN_SPACING = 1; public const float HIT_TARGET_POSITION = 110; @@ -40,13 +44,6 @@ public class Stage : ScrollingPlayfield private readonly Drawable barLineContainer; - private readonly Dictionary columnColours = new Dictionary - { - { ColumnType.Even, new Color4(6, 84, 0, 255) }, - { ColumnType.Odd, new Color4(94, 0, 57, 255) }, - { ColumnType.Special, new Color4(0, 48, 63, 255) } - }; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Columns.Any(c => c.ReceivePositionalInputAt(screenSpacePos)); private readonly int firstColumnIndex; @@ -54,6 +51,7 @@ public class Stage : ScrollingPlayfield public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) { this.firstColumnIndex = firstColumnIndex; + Definition = definition; Name = "Stage"; @@ -75,7 +73,7 @@ public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction n AutoSizeAxes = Axes.X, Children = new Drawable[] { - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: definition), _ => new DefaultStageBackground()) + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { RelativeSizeAxes = Axes.Both }, @@ -100,7 +98,7 @@ public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction n RelativeSizeAxes = Axes.Y, } }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: definition), _ => null) + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground), _ => null) { RelativeSizeAxes = Axes.Both }, @@ -118,15 +116,13 @@ public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction n for (int i = 0; i < definition.Columns; i++) { - var columnType = definition.GetTypeOfColumn(i); + bool isSpecial = definition.IsSpecialColumn(i); - var column = new Column(firstColumnIndex + i) + var column = new Column(firstColumnIndex + i, isSpecial) { RelativeSizeAxes = Axes.Both, Width = 1, - ColumnType = columnType, - AccentColour = columnColours[columnType], - Action = { Value = columnType == ColumnType.Special ? specialColumnStartAction++ : normalColumnStartAction++ } + Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ } }; topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); @@ -135,6 +131,37 @@ public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction n } } + private ISkinSource currentSkin; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + currentSkin = skin; + + skin.SourceChanged += onSkinChanged; + onSkinChanged(); + } + + private void onSkinChanged() + { + float paddingTop = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingTop))?.Value ?? 0; + float paddingBottom = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingBottom))?.Value ?? 0; + + Padding = new MarginPadding + { + Top = paddingTop, + Bottom = paddingBottom, + }; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (currentSkin != null) + currentSkin.SourceChanged -= onSkinChanged; + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index c102678e002e..1b67fc2ca90a 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -4,14 +4,18 @@ #nullable disable using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; @@ -33,6 +37,9 @@ public class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Cached] private readonly EditorClock editorClock; @@ -48,6 +55,7 @@ public class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene }; private OsuDistanceSnapGrid grid; + private SnappingCursorContainer cursor; public TestSceneOsuDistanceSnapGrid() { @@ -84,8 +92,8 @@ public void Setup() => Schedule(() => RelativeSizeAxes = Axes.Both, Colour = Color4.SlateGray }, + cursor = new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }, grid = new OsuDistanceSnapGrid(new HitCircle { Position = grid_position }), - new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position } }; }); @@ -150,6 +158,37 @@ public void TestDistanceSpacingAdjust(float multiplier, float expectedDistance) assertSnappedDistance(expectedDistance); } + [Test] + public void TestReferenceObjectNotOnSnapGrid() + { + AddStep("create grid", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + cursor = new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }, + grid = new OsuDistanceSnapGrid(new HitCircle + { + Position = grid_position, + // This is important. It sets the reference object to a point in time that isn't on the current snap divisor's grid. + // We are testing that the grid's display is offset correctly. + StartTime = 40, + }), + }; + }); + + AddStep("move mouse to point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2(beat_length, 0) * 2))); + + AddAssert("Ensure cursor is on a grid line", () => + { + return grid.ChildrenOfType().Any(p => Precision.AlmostEquals(p.ScreenSpaceDrawQuad.TopRight.X, grid.ToScreenSpace(cursor.LastSnappedPosition).X)); + }); + } + [Test] public void TestLimitedDistance() { @@ -162,8 +201,8 @@ public void TestLimitedDistance() RelativeSizeAxes = Axes.Both, Colour = Color4.SlateGray }, + cursor = new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }, grid = new OsuDistanceSnapGrid(new HitCircle { Position = grid_position }, new HitCircle { StartTime = 200 }), - new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position } }; }); @@ -182,6 +221,8 @@ private class SnappingCursorContainer : CompositeDrawable { public Func GetSnapPosition; + public Vector2 LastSnappedPosition { get; private set; } + private readonly Drawable cursor; private InputManager inputManager; @@ -210,7 +251,7 @@ protected override void LoadComplete() protected override void Update() { base.Update(); - cursor.Position = GetSnapPosition.Invoke(inputManager.CurrentState.Mouse.Position); + cursor.Position = LastSnappedPosition = GetSnapPosition.Invoke(inputManager.CurrentState.Mouse.Position); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index 1e7388554000..f9cea5761b5e 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -20,20 +20,49 @@ public class TestSceneOsuEditorGrids : EditorTestScene protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); [Test] - public void TestGridExclusivity() + public void TestGridToggles() { AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); rectangularGridActive(false); AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType().Any()); + rectangularGridActive(true); + + AddStep("disable distance snap grid", () => InputManager.Key(Key.T)); AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); rectangularGridActive(true); - AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("disable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("distance snap grid still hidden", () => !this.ChildrenOfType().Any()); + rectangularGridActive(false); + } + + [Test] + public void TestDistanceSnapMomentaryToggle() + { AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft)); AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestGridSnapMomentaryToggle() + { + rectangularGridActive(false); + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + rectangularGridActive(true); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); rectangularGridActive(false); } @@ -50,8 +79,6 @@ private void rectangularGridActive(bool active) AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0))); else AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1))); - - AddStep("choose selection tool", () => InputManager.Key(Key.Number1)); } [Test] diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs index 51871dd9e5db..0601dc606859 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -148,6 +148,37 @@ private void convertToStream() }); } + [Test] + public void TestFloatEdgeCaseConversion() + { + Slider slider = null; + + AddStep("select first slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + AddStep("change to these specific circumstances", () => + { + EditorBeatmap.Difficulty.SliderMultiplier = 1; + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(slider.StartTime); + timingPoint.BeatLength = 352.941176470588; + slider.Path.ControlPoints[^1].Position = new Vector2(-110, 16); + slider.Path.ExpectedDistance.Value = 100; + }); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.25, pathPosition: 0.25), + (time: 0.5, pathPosition: 0.5), + (time: 0.75, pathPosition: 0.75), + (time: 1, pathPosition: 1))); + } + private bool streamCreatedFor(Slider slider, params (double time, double pathPosition)[] expectedCircles) { if (EditorBeatmap.HitObjects.Contains(slider)) diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs new file mode 100644 index 000000000000..704a548c61f0 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs @@ -0,0 +1,25 @@ +// 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.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModFlashlight : OsuModTestScene + { + [TestCase(600)] + [TestCase(120)] + [TestCase(1200)] + public void TestFollowDelay(double followDelay) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { FollowDelay = { Value = followDelay } }, PassCondition = () => true }); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.5f)] + [TestCase(2f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs new file mode 100644 index 000000000000..7d7b2d907148 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModFreezeFrame : OsuModTestScene + { + [Test] + public void TestFreezeFrame() + { + CreateModTest(new ModTestData + { + Mod = new OsuModFreezeFrame(), + PassCondition = () => true, + Autoplay = false, + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs index 44404ca245ba..da6fac3269e8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Utils; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -12,6 +13,7 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods @@ -145,6 +147,10 @@ public void TestVisibleAfterComboBreak() private bool isBreak() => Player.IsBreakTime.Value; - private bool cursorAlphaAlmostEquals(float alpha) => Precision.AlmostEquals(Player.DrawableRuleset.Cursor.Alpha, alpha, 0.1f); + private OsuPlayfield playfield => (OsuPlayfield)Player.DrawableRuleset.Playfield; + + private bool cursorAlphaAlmostEquals(float alpha) => + Precision.AlmostEquals(playfield.Cursor.AsNonNull().Alpha, alpha, 0.1f) && + Precision.AlmostEquals(playfield.Smoke.Alpha, alpha, 0.1f); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png new file mode 100644 index 000000000000..b1380a47a47f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png new file mode 100644 index 000000000000..5f7beae4e9dd Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs index 1665c40b4041..ed1891b7d91b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -377,7 +377,7 @@ public void TestHitSliderHeadBeforeHitCircle() private void addJudgementAssert(OsuHitObject hitObject, HitResult result) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", - () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + () => judgementResults.Single(r => r.HitObject == hitObject).Type, () => Is.EqualTo(result)); } private void addJudgementAssert(string name, Func hitObject, HitResult result) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs deleted file mode 100644 index e0d1646cb07f..000000000000 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Tests.Visual; - -namespace osu.Game.Rulesets.Osu.Tests -{ - public class TestSceneOsuFlashlight : TestSceneOsuPlayer - { - protected override TestPlayer CreatePlayer(Ruleset ruleset) - { - SelectedMods.Value = new Mod[] { new OsuModAutoplay(), new OsuModFlashlight(), }; - - return base.CreatePlayer(ruleset); - } - } -} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index 016962786722..728aa27da20a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; @@ -68,10 +69,8 @@ public void TestBallTintChangedOnAccentChange() AddStep("create slider", () => { - var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); - tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1"; - - var provider = Ruleset.Value.CreateInstance().CreateSkinTransformer(tintingSkin, Beatmap.Value.Beatmap); + var skin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); + var provider = Ruleset.Value.CreateInstance().CreateSkinTransformer(skin, Beatmap.Value.Beatmap); Child = new SkinProvidingContainer(provider) { @@ -92,10 +91,10 @@ public void TestBallTintChangedOnAccentChange() }); AddStep("set accent white", () => dho.AccentColour.Value = Color4.White); - AddAssert("ball is white", () => dho.ChildrenOfType().Single().AccentColour == Color4.White); + AddAssert("ball is white", () => dho.ChildrenOfType().Single().BallColour == Color4.White); AddStep("set accent red", () => dho.AccentColour.Value = Color4.Red); - AddAssert("ball is red", () => dho.ChildrenOfType().Single().AccentColour == Color4.Red); + AddAssert("ball is red", () => dho.ChildrenOfType().Single().BallColour == Color4.Red); } private Slider prepareObject(Slider slider) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs new file mode 100644 index 000000000000..1cb64b71fce5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs @@ -0,0 +1,136 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Framework.Logging; +using osu.Framework.Testing.Input; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneSmoke : OsuSkinnableTestScene + { + [Test] + public void TestSmoking() + { + addStep("Create short smoke", 2_000); + addStep("Create medium smoke", 5_000); + addStep("Create long smoke", 10_000); + } + + private void addStep(string stepName, double duration) + { + var smokeContainers = new List(); + + AddStep(stepName, () => + { + smokeContainers.Clear(); + SetContents(_ => + { + smokeContainers.Add(new TestSmokeContainer + { + Duration = duration, + RelativeSizeAxes = Axes.Both + }); + + return new SmokingInputManager + { + Duration = duration, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.95f), + Child = smokeContainers[^1], + }; + }); + }); + + AddUntilStep("Until skinnable expires", () => + { + if (smokeContainers.Count == 0) + return false; + + Logger.Log("How many: " + smokeContainers.Count); + + foreach (var smokeContainer in smokeContainers) + { + if (smokeContainer.Children.Count != 0) + return false; + } + + return true; + }); + } + + private class SmokingInputManager : ManualInputManager + { + public double Duration { get; init; } + + private double? startTime; + + public SmokingInputManager() + { + UseParentInput = false; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + MoveMouseTo(ToScreenSpace(DrawSize / 2)); + } + + protected override void Update() + { + base.Update(); + + const float spin_angle = 4 * MathF.PI; + + startTime ??= Time.Current; + + float fraction = (float)((Time.Current - startTime) / Duration); + + float angle = fraction * spin_angle; + float radius = fraction * Math.Min(DrawSize.X, DrawSize.Y) / 2; + + Vector2 pos = radius * new Vector2(MathF.Cos(angle), MathF.Sin(angle)) + DrawSize / 2; + MoveMouseTo(ToScreenSpace(pos)); + } + } + + private class TestSmokeContainer : SmokeContainer + { + public double Duration { get; init; } + + private bool isPressing; + private bool isFinished; + + private double? startTime; + + protected override void Update() + { + base.Update(); + + startTime ??= Time.Current + 0.1; + + if (!isPressing && !isFinished && Time.Current > startTime) + { + OnPressed(new KeyBindingPressEvent(new InputState(), OsuAction.Smoke)); + isPressing = true; + isFinished = false; + } + + if (isPressing && Time.Current > startTime + Duration) + { + OnReleased(new KeyBindingReleaseEvent(new InputState(), OsuAction.Smoke)); + isPressing = false; + isFinished = true; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index c2973644cfdc..1eb1c85d93b6 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -1,10 +1,10 @@  - + - + WinExe diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs index bb967a0a760b..da2a6ced67fd 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs @@ -40,16 +40,23 @@ private void load(OsuColour colours) body.BorderColour = colours.Yellow; } + private int? lastVersion; + public override void UpdateFrom(Slider hitObject) { base.UpdateFrom(hitObject); body.PathRadius = hitObject.Scale * OsuHitObject.OBJECT_RADIUS; - var vertices = new List(); - hitObject.Path.GetPathToProgress(vertices, 0, 1); + if (lastVersion != hitObject.Path.Version.Value) + { + lastVersion = hitObject.Path.Version.Value; + + var vertices = new List(); + hitObject.Path.GetPathToProgress(vertices, 0, 1); - body.SetVertices(vertices); + body.SetVertices(vertices); + } Size = body.Size; OriginPosition = body.PathOffset; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 7c289b5b05e2..265a1d21b15c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -342,7 +342,7 @@ private void convertToStream() double positionWithRepeats = (time - HitObject.StartTime) / HitObject.Duration * HitObject.SpanCount(); double pathPosition = positionWithRepeats - (int)positionWithRepeats; // every second span is in the reverse direction - need to reverse the path position. - if (Precision.AlmostBigger(positionWithRepeats % 2, 1)) + if (positionWithRepeats % 2 >= 1) pathPosition = 1 - pathPosition; Vector2 position = HitObject.Position + HitObject.Path.PositionAt(pathPosition); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs index 28690ee0b783..b5a13a22ceb2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs @@ -5,19 +5,17 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Skinning.Default; using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components { public class SpinnerPiece : BlueprintPiece { - private readonly CircularContainer circle; - private readonly RingPiece ring; + private readonly Circle circle; + private readonly Circle ring; public SpinnerPiece() { @@ -25,18 +23,21 @@ public SpinnerPiece() RelativeSizeAxes = Axes.Both; FillMode = FillMode.Fit; - Size = new Vector2(1.3f); + Size = new Vector2(1); InternalChildren = new Drawable[] { - circle = new CircularContainer + circle = new Circle { RelativeSizeAxes = Axes.Both, - Masking = true, Alpha = 0.5f, - Child = new Box { RelativeSizeAxes = Axes.Both } }, - ring = new RingPiece() + ring = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(OsuHitObject.OBJECT_RADIUS), + }, }; } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 60896b17bf3b..1460fae4d753 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -13,8 +13,11 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -44,12 +47,10 @@ protected override DrawableRuleset CreateDrawableRuleset(Ruleset r new SpinnerCompositionTool() }; - private readonly Bindable distanceSnapToggle = new Bindable(); private readonly Bindable rectangularGridSnapToggle = new Bindable(); protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }), new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) }); @@ -60,6 +61,9 @@ protected override IEnumerable CreateTernaryButtons() => base.Cre [BackgroundDependencyLoader] private void load() { + // Give a bit of breathing room around the playfield content. + PlayfieldContentContainer.Padding = new MarginPadding(10); + LayerBelowRuleset.AddRange(new Drawable[] { distanceSnapGridContainer = new Container @@ -77,19 +81,7 @@ private void load() placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); - distanceSnapToggle.ValueChanged += _ => - { - updateDistanceSnapGrid(); - - if (distanceSnapToggle.Value == TernaryState.True) - rectangularGridSnapToggle.Value = TernaryState.False; - }; - - rectangularGridSnapToggle.ValueChanged += _ => - { - if (rectangularGridSnapToggle.Value == TernaryState.True) - distanceSnapToggle.Value = TernaryState.False; - }; + DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); // we may be entering the screen with a selection already active updateDistanceSnapGrid(); @@ -109,6 +101,14 @@ public override string ConvertSelectionToString() private RectangularPositionSnapGrid rectangularPositionSnapGrid; + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + { + float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); + float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position); + + return actualDistance / expectedDistance; + } + protected override void Update() { base.Update(); @@ -129,24 +129,46 @@ protected override void Update() public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { if (snapType.HasFlagFast(SnapType.NearbyObjects) && snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) + { + // In the case of snapping to nearby objects, a time value is not provided. + // This matches the stable editor (which also uses current time), but with the introduction of time-snapping distance snap + // this could result in unexpected behaviour when distance snapping is turned on and a user attempts to place an object that is + // BOTH on a valid distance snap ring, and also at the same position as a previous object. + // + // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. + // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over + // the time value if the proposed positions are roughly the same. + if (snapType.HasFlagFast(SnapType.Grids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + { + (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); + if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) + snapResult.Time = distanceSnappedTime; + } + return snapResult; + } + + SnapResult result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); if (snapType.HasFlagFast(SnapType.Grids)) { - if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); - return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + + result.ScreenSpacePosition = distanceSnapGrid.ToScreenSpace(pos); + result.Time = time; } if (rectangularGridSnapToggle.Value == TernaryState.True) { - Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); - return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); + + result.ScreenSpacePosition = rectangularPositionSnapGrid.ToScreenSpace(pos); } } - return base.FindSnappedPositionAndTime(screenSpacePosition, snapType); + return result; } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) @@ -199,7 +221,7 @@ private void updateDistanceSnapGrid() distanceSnapGridCache.Invalidate(); distanceSnapGrid = null; - if (distanceSnapToggle.Value != TernaryState.True) + if (DistanceSnapToggle.Value != TernaryState.True) return; switch (BlueprintContainer.CurrentTool) @@ -226,6 +248,42 @@ private void updateDistanceSnapGrid() } } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) + return false; + + handleToggleViaKey(e); + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + handleToggleViaKey(e); + base.OnKeyUp(e); + } + + protected override bool AdjustDistanceSpacing(GlobalAction action, float amount) + { + // To allow better visualisation, ensure that the spacing grid is visible before adjusting. + DistanceSnapToggle.Value = TernaryState.True; + + return base.AdjustDistanceSpacing(action, amount); + } + + private bool gridSnapMomentary; + + private void handleToggleViaKey(KeyboardEvent key) + { + bool shiftPressed = key.ShiftPressed; + + if (shiftPressed != gridSnapMomentary) + { + gridSnapMomentary = shiftPressed; + rectangularGridSnapToggle.Value = rectangularGridSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; + } + } + private DistanceSnapGrid createDistanceSnapGrid(IEnumerable selectedHitObjects) { if (BlueprintContainer.CurrentTool is SpinnerCompositionTool) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs index ec93f19e1789..f213d9f193e4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs @@ -21,7 +21,7 @@ public class OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject, IReq public override double ScoreMultiplier => 1; public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle; - public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles) }; + public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModFreezeFrame) }; [SettingSource("Initial size", "Change the initial size of the approach circle, relative to hit circles.", 0)] public BindableFloat Scale { get; } = new BindableFloat(4) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 79f5eed139cd..1a86901d9c95 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -41,7 +41,7 @@ public class OsuModFlashlight : ModFlashlight, IApplicableToDrawab public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 180; + public override float DefaultFlashlightSize => 200; private OsuFlashlight flashlight = null!; @@ -62,7 +62,8 @@ public OsuFlashlight(OsuModFlashlight modFlashlight) { followDelay = modFlashlight.FollowDelay.Value; - FlashlightSize = new Vector2(0, GetSizeFor(0)); + FlashlightSize = new Vector2(0, GetSize()); + FlashlightSmoothness = 1.4f; } public void OnSliderTrackingChange(ValueChangedEvent e) @@ -82,9 +83,9 @@ protected override bool OnMouseMove(MouseMoveEvent e) return base.OnMouseMove(e); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs new file mode 100644 index 000000000000..bea5d4f5d9ea --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModFreezeFrame : Mod, IApplicableToDrawableHitObject, IApplicableToBeatmap + { + public override string Name => "Freeze Frame"; + + public override string Acronym => "FR"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => "Burn the notes into your memory."; + + //Alters the transforms of the approach circles, breaking the effects of these mods. + public override Type[] IncompatibleMods => new[] { typeof(OsuModApproachDifferent) }; + + public override ModType Type => ModType.Fun; + + //mod breaks normal approach circle preempt + private double originalPreempt; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var firstHitObject = beatmap.HitObjects.OfType().FirstOrDefault(); + if (firstHitObject == null) + return; + + double lastNewComboTime = 0; + + originalPreempt = firstHitObject.TimePreempt; + + foreach (var obj in beatmap.HitObjects.OfType()) + { + if (obj.NewCombo) { lastNewComboTime = obj.StartTime; } + + applyFadeInAdjustment(obj); + } + + void applyFadeInAdjustment(OsuHitObject osuObject) + { + osuObject.TimePreempt += osuObject.StartTime - lastNewComboTime; + + foreach (var nested in osuObject.NestedHitObjects.OfType()) + { + switch (nested) + { + //SliderRepeat wont layer correctly if preempt is changed. + case SliderRepeat: + break; + + default: + applyFadeInAdjustment(nested); + break; + } + } + } + } + + public void ApplyToDrawableHitObject(DrawableHitObject drawableObject) + { + drawableObject.ApplyCustomUpdateState += (drawableHitObject, _) => + { + if (drawableHitObject is not DrawableHitCircle drawableHitCircle) return; + + var hitCircle = drawableHitCircle.HitObject; + var approachCircle = drawableHitCircle.ApproachCircle; + + // Reapply scale, ensuring the AR isn't changed due to the new preempt. + approachCircle.ClearTransforms(targetMember: nameof(approachCircle.Scale)); + approachCircle.ScaleTo(4 * (float)(hitCircle.TimePreempt / originalPreempt)); + + using (drawableHitCircle.ApproachCircle.BeginAbsoluteSequence(hitCircle.StartTime - hitCircle.TimePreempt)) + approachCircle.ScaleTo(1, hitCircle.TimePreempt).Then().Expire(); + }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index fbde9e0491bc..38d90eb12102 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Timing; @@ -46,7 +47,7 @@ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset public void Update(Playfield playfield) { - var cursorPos = playfield.Cursor.ActiveCursor.DrawPosition; + var cursorPos = playfield.Cursor.AsNonNull().ActiveCursor.DrawPosition; foreach (var drawable in playfield.HitObjectContainer.AliveObjects) { diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs index 2f84c3058175..d1bbae8e1aca 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Localisation; @@ -9,6 +10,7 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osu.Game.Utils; @@ -33,9 +35,15 @@ public void ApplyToBeatmap(IBeatmap beatmap) public void Update(Playfield playfield) { - bool shouldAlwaysShowCursor = IsBreakTime.Value || spinnerPeriods.IsInAny(playfield.Clock.CurrentTime); + var osuPlayfield = (OsuPlayfield)playfield; + Debug.Assert(osuPlayfield.Cursor != null); + + bool shouldAlwaysShowCursor = IsBreakTime.Value || spinnerPeriods.IsInAny(osuPlayfield.Clock.CurrentTime); float targetAlpha = shouldAlwaysShowCursor ? 1 : ComboBasedAlpha; - playfield.Cursor.Alpha = (float)Interpolation.Lerp(playfield.Cursor.Alpha, targetAlpha, Math.Clamp(playfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + float currentAlpha = (float)Interpolation.Lerp(osuPlayfield.Cursor.Alpha, targetAlpha, Math.Clamp(osuPlayfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + + osuPlayfield.Cursor.Alpha = currentAlpha; + osuPlayfield.Smoke.Alpha = currentAlpha; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index fac1cbfd47a0..753de6231a74 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -20,7 +20,9 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { public override LocalisableString Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); + + public override Type[] IncompatibleMods => + base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); /// /// How early before a hitobject's start time to trigger a hit. @@ -51,7 +53,7 @@ public void ApplyToPlayer(Player player) return; } - osuInputManager.AllowUserPresses = false; + osuInputManager.AllowGameplayInputs = false; } public void Update(Playfield playfield) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs index 911363a27ea0..31a6b69d6baa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Localisation; using osu.Framework.Timing; using osu.Framework.Utils; @@ -45,7 +46,7 @@ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset public void Update(Playfield playfield) { - var cursorPos = playfield.Cursor.ActiveCursor.DrawPosition; + var cursorPos = playfield.Cursor.AsNonNull().ActiveCursor.DrawPosition; foreach (var drawable in playfield.HitObjectContainer.AliveObjects) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index c5992b359dd3..841a52da7b17 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -102,8 +102,8 @@ private void load() Size = HitArea.DrawSize; - PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); - StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + PositionBindable.BindValueChanged(_ => UpdatePosition()); + StackHeightBindable.BindValueChanged(_ => UpdatePosition()); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } @@ -134,6 +134,11 @@ public override double LifetimeEnd } } + protected virtual void UpdatePosition() + { + Position = HitObject.StackedPosition; + } + public override void Shake() => shakeContainer.Shake(); protected override void CheckForResult(bool userTriggered, double timeOffset) @@ -204,12 +209,12 @@ protected override void UpdateHitStateTransforms(ArmedState state) // todo: temporary / arbitrary, used for lifetime optimisation. this.Delay(800).FadeOut(); - // in the case of an early state change, the fade should be expedited to the current point in time. - if (HitStateUpdateTime < HitObject.StartTime) - ApproachCircle.FadeOut(50); - switch (state) { + default: + ApproachCircle.FadeOut(); + break; + case ArmedState.Idle: HitArea.HitAction = null; break; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 6f4ca30bd073..d9d0d284777e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -11,7 +11,9 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Scoring; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -64,6 +66,23 @@ protected override void OnFree() ScaleBindable.UnbindFrom(HitObject.ScaleBindable); } + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // Dim should only be applied at a top level, as it will be implicitly applied to nested objects. + if (ParentHitObject == null) + { + // Of note, no one noticed this was missing for years, but it definitely feels like it should still exist. + // For now this is applied across all skins, and matches stable. + // For simplicity, dim colour is applied to the DrawableHitObject itself. + // We may need to make a nested container setup if this even causes a usage conflict (ie. with a mod). + this.FadeColour(new Color4(195, 195, 195, 255)); + using (BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) + this.FadeColour(Color4.White, 100); + } + } + protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; private OsuInputManager osuActionInputManager; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index d58a43572816..785d15c15b57 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -14,12 +14,10 @@ using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -106,7 +104,6 @@ private void load() { foreach (var drawableHitObject in NestedHitObjects) drawableHitObject.AccentColour.Value = colour.NewValue; - updateBallTint(); }, true); Tracking.BindValueChanged(updateSlidingSample); @@ -257,22 +254,6 @@ public override void OnKilled() SliderBody?.RecyclePath(); } - protected override void ApplySkin(ISkinSource skin, bool allowFallback) - { - base.ApplySkin(skin, allowFallback); - - updateBallTint(); - } - - private void updateBallTint() - { - if (CurrentSkin == null) - return; - - bool allowBallTint = CurrentSkin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; - Ball.AccentColour = allowBallTint ? AccentColour.Value : Color4.White; - } - protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered || Time.Current < HitObject.EndTime) @@ -331,7 +312,7 @@ protected override void UpdateHitStateTransforms(ArmedState state) { base.UpdateHitStateTransforms(state); - const float fade_out_time = 450; + const float fade_out_time = 240; switch (state) { @@ -341,7 +322,7 @@ protected override void UpdateHitStateTransforms(ArmedState state) break; } - this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); + this.FadeOut(fade_out_time).Expire(); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 6bfb4e8aaeb6..de6ca7dd3872 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -11,28 +11,20 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition, IHasAccentColour + public class DrawableSliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition { public const float FOLLOW_AREA = 2.4f; public Func GetInitialHitAction; - public Color4 AccentColour - { - get => ball.Colour; - set => ball.Colour = value; - } - private Drawable followCircleReceptor; private DrawableSlider drawableSlider; private Drawable ball; @@ -186,17 +178,22 @@ private bool isValidTrackingAction(OsuAction action) private Vector2? lastPosition; + private bool rewinding; + public void UpdateProgress(double completionProgress) { Position = drawableSlider.HitObject.CurvePositionAt(completionProgress); var diff = lastPosition.HasValue ? lastPosition.Value - Position : Position - drawableSlider.HitObject.CurvePositionAt(completionProgress + 0.01f); + if (Clock.ElapsedFrameTime != 0) + rewinding = Clock.ElapsedFrameTime < 0; + // Ensure the value is substantially high enough to allow for Atan2 to get a valid angle. if (diff.LengthFast < 0.01f) return; - ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); + ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI) + (rewinding ? 180 : 0); lastPosition = Position; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 80b9544e5bea..d1d749d7e238 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -6,7 +6,6 @@ using System; using System.Diagnostics; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -43,13 +42,6 @@ public DrawableSliderHead(SliderHeadCircle h) { } - [BackgroundDependencyLoader] - private void load() - { - PositionBindable.BindValueChanged(_ => updatePosition()); - pathVersion.BindValueChanged(_ => updatePosition()); - } - protected override void OnFree() { base.OnFree(); @@ -57,6 +49,11 @@ protected override void OnFree() pathVersion.UnbindFrom(DrawableSlider.PathVersion); } + protected override void UpdatePosition() + { + // Slider head is always drawn at (0,0). + } + protected override void OnApply() { base.OnApply(); @@ -100,11 +97,5 @@ public override void Shake() base.Shake(); DrawableSlider.Shake(); } - - private void updatePosition() - { - if (Slider != null) - Position = HitObject.Position - Slider.Position; - } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 53f4d2197503..6ae9d5bc3402 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -57,7 +57,7 @@ public class DrawableSpinner : DrawableOsuHitObject /// public readonly IBindable SpinsPerMinute = new BindableDouble(); - private const double fade_out_duration = 160; + private const double fade_out_duration = 240; public DrawableSpinner() : this(null) diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index e3c1b1e16808..6c2be8a49ad7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -34,21 +34,6 @@ public double Duration public override IList AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray(); - public IList CreateSlidingSamples() - { - var slidingSamples = new List(); - - var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); - if (normalSample != null) - slidingSamples.Add(normalSample.With("sliderslide")); - - var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); - if (whistleSample != null) - slidingSamples.Add(whistleSample.With("sliderwhistle")); - - return slidingSamples; - } - private readonly Cached endPositionCache = new Cached(); public override Vector2 EndPosition => endPositionCache.IsValid ? endPositionCache.Value : endPositionCache.Value = Position + this.CurvePositionAt(1); diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index 12256e93d0d7..1e59e192461c 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -5,10 +5,12 @@ using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Input.StateChanges.Events; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu @@ -17,9 +19,16 @@ public class OsuInputManager : RulesetInputManager { public IEnumerable PressedActions => KeyBindingContainer.PressedActions; - public bool AllowUserPresses + /// + /// Whether gameplay input buttons should be allowed. + /// Defaults to true, generally used for mods like Relax which turn off main inputs. + /// + /// + /// Of note, auxiliary inputs like the "smoke" key are left usable. + /// + public bool AllowGameplayInputs { - set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowUserPresses = value; + set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowGameplayInputs = value; } /// @@ -58,18 +67,36 @@ protected override bool HandleMouseTouchStateChange(TouchStateChangeEvent e) private class OsuKeyBindingContainer : RulesetKeyBindingContainer { - public bool AllowUserPresses = true; + private bool allowGameplayInputs = true; + + /// + /// Whether gameplay input buttons should be allowed. + /// Defaults to true, generally used for mods like Relax which turn off main inputs. + /// + /// + /// Of note, auxiliary inputs like the "smoke" key are left usable. + /// + public bool AllowGameplayInputs + { + get => allowGameplayInputs; + set + { + allowGameplayInputs = value; + ReloadMappings(); + } + } public OsuKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) : base(ruleset, variant, unique) { } - protected override bool Handle(UIEvent e) + protected override void ReloadMappings(IQueryable realmKeyBindings) { - if (!AllowUserPresses) return false; + base.ReloadMappings(realmKeyBindings); - return base.Handle(e); + if (!AllowGameplayInputs) + KeyBindings = KeyBindings.Where(b => b.GetAction() == OsuAction.Smoke).ToList(); } } } @@ -80,6 +107,9 @@ public enum OsuAction LeftButton, [Description("Right button")] - RightButton + RightButton, + + [Description("Smoke")] + Smoke, } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 3f5e72865185..69df12ff6d7b 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -59,6 +59,7 @@ public override IEnumerable GetDefaultKeyBindings(int variant = 0) = { new KeyBinding(InputKey.Z, OsuAction.LeftButton), new KeyBinding(InputKey.X, OsuAction.RightButton), + new KeyBinding(InputKey.C, OsuAction.Smoke), new KeyBinding(InputKey.MouseLeft, OsuAction.LeftButton), new KeyBinding(InputKey.MouseRight, OsuAction.RightButton), }; @@ -200,7 +201,8 @@ public override IEnumerable GetModsFor(ModType type) new OsuModMuted(), new OsuModNoScope(), new MultiMod(new OsuModMagnetised(), new OsuModRepel()), - new ModAdaptiveSpeed() + new ModAdaptiveSpeed(), + new OsuModFreezeFrame() }; case ModType.System: diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index fcf079b6aa4f..4248cce55a40 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -21,6 +21,7 @@ public enum OsuSkinComponents SliderBall, SliderBody, SpinnerBody, + CursorSmoke, ApproachCircle, } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs index 85060261feca..8082c5aef455 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs @@ -31,6 +31,7 @@ public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayF Position = currentFrame.Position; if (currentFrame.MouseLeft) Actions.Add(OsuAction.LeftButton); if (currentFrame.MouseRight) Actions.Add(OsuAction.RightButton); + if (currentFrame.Smoke) Actions.Add(OsuAction.Smoke); } public LegacyReplayFrame ToLegacy(IBeatmap beatmap) @@ -41,6 +42,8 @@ public LegacyReplayFrame ToLegacy(IBeatmap beatmap) state |= ReplayButtonState.Left1; if (Actions.Contains(OsuAction.RightButton)) state |= ReplayButtonState.Right1; + if (Actions.Contains(OsuAction.Smoke)) + state |= ReplayButtonState.Smoke; return new LegacyReplayFrame(Time, Position.X, Position.Y, state); } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs index 05fbac625eb5..6f55e1790fe6 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -1,20 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { public class OsuHitWindows : HitWindows { + /// + /// osu! ruleset has a fixed miss window regardless of difficulty settings. + /// + public const double MISS_WINDOW = 400; + private static readonly DifficultyRange[] osu_ranges = { new DifficultyRange(HitResult.Great, 80, 50, 20), new DifficultyRange(HitResult.Ok, 140, 100, 60), new DifficultyRange(HitResult.Meh, 200, 150, 100), - new DifficultyRange(HitResult.Miss, 400, 400, 400), + new DifficultyRange(HitResult.Miss, MISS_WINDOW, MISS_WINDOW, MISS_WINDOW), }; public override bool IsHitResultAllowed(HitResult result) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs index b08b7b4e8550..bb68c7298fb0 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs @@ -75,6 +75,7 @@ public virtual void PlayAnimation() { default: JudgementText + .FadeInFromZero(300, Easing.OutQuint) .ScaleTo(Vector2.One) .ScaleTo(new Vector2(1.2f), 1800, Easing.OutQuint); break; @@ -96,7 +97,7 @@ public virtual void PlayAnimation() ringExplosion?.PlayAnimation(); } - public Drawable? GetAboveHitObjectsProxiedContent() => null; + public Drawable? GetAboveHitObjectsProxiedContent() => JudgementText.CreateProxy(); private class RingExplosion : CompositeDrawable { diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs index ffdcba3cdb6f..36dc8c801d97 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs @@ -108,18 +108,23 @@ protected override void LoadComplete() { base.LoadComplete(); + indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + accentColour.BindValueChanged(colour => { - outerFill.Colour = innerFill.Colour = colour.NewValue.Darken(4); + // A colour transform is applied. + // Without removing transforms first, when it is rewound it may apply an old colour. + outerGradient.ClearTransforms(targetMember: nameof(Colour)); outerGradient.Colour = ColourInfo.GradientVertical(colour.NewValue, colour.NewValue.Darken(0.1f)); + + outerFill.Colour = innerFill.Colour = colour.NewValue.Darken(4); innerGradient.Colour = ColourInfo.GradientVertical(colour.NewValue.Darken(0.5f), colour.NewValue.Darken(0.6f)); flash.Colour = colour.NewValue; - }, true); - indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + updateStateTransforms(drawableObject, drawableObject.State.Value); + }, true); drawableObject.ApplyCustomUpdateState += updateStateTransforms; - updateStateTransforms(drawableObject, drawableObject.State.Value); } private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) @@ -173,11 +178,7 @@ private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedSta .FadeOut(flash_in_duration); } - // The flash layer starts white to give the wanted brightness, but is almost immediately - // recoloured to the accent colour. This would more correctly be done with two layers (one for the initial flash) - // but works well enough with the colour fade. flash.FadeTo(1, flash_in_duration, Easing.OutQuint); - flash.FlashColour(accentColour.Value, fade_out_time, Easing.OutQuint); this.FadeOut(fade_out_time, Easing.OutQuad); break; diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs index 7bc6723afb92..bf507db50c3f 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs @@ -22,6 +22,7 @@ public OsuArgonSkinTransformer(ISkin skin) return new ArgonJudgementPiece(resultComponent.Component); case OsuSkinComponent osuComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. switch (osuComponent.Component) { case OsuSkinComponents.HitCircle: diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs new file mode 100644 index 000000000000..27a2dc396015 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public class DefaultSmokeSegment : SmokeSegment + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + // ISkinSource doesn't currently fallback to global textures. + // We might want to change this in the future if the intention is to allow the user to skin this as per legacy skins. + Texture = textures.Get("Gameplay/osu/cursor-smoke"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 1b2ab8204481..a6e62b83e4be 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -68,7 +68,7 @@ private void load() InternalChildren = new[] { - CircleSprite = new KiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) + CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -77,7 +77,7 @@ private void load() { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = OverlaySprite = new KiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) + Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -134,10 +134,10 @@ private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedSta switch (state) { case ArmedState.Hit: - CircleSprite.FadeOut(legacy_fade_duration, Easing.Out); + CircleSprite.FadeOut(legacy_fade_duration); CircleSprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); - OverlaySprite.FadeOut(legacy_fade_duration, Easing.Out); + OverlaySprite.FadeOut(legacy_fade_duration); OverlaySprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); if (hasNumber) @@ -146,11 +146,11 @@ private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedSta if (legacyVersion >= 2.0m) // legacy skins of version 2.0 and newer only apply very short fade out to the number piece. - hitCircleText.FadeOut(legacy_fade_duration / 4, Easing.Out); + hitCircleText.FadeOut(legacy_fade_duration / 4); else { // old skins scale and fade it normally along other pieces. - hitCircleText.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleText.FadeOut(legacy_fade_duration); hitCircleText.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index 22944becf3a4..71c3e4c9f0d9 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -107,8 +107,8 @@ protected override void UpdateStateTransforms(DrawableHitObject drawableHitObjec using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) this.FadeOut(); - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2)) - this.FadeInFromZero(spinner.TimeFadeIn / 2); + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn)) + this.FadeInFromZero(spinner.TimeFadeIn); using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) { diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs index 414879f42d94..60d71ae84366 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.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; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -21,6 +22,8 @@ public class LegacySliderBall : CompositeDrawable [Resolved(canBeNull: true)] private DrawableHitObject? parentObject { get; set; } + public Color4 BallColour => animationContent.Colour; + private Sprite layerNd = null!; private Sprite layerSpec = null!; @@ -61,6 +64,8 @@ private void load() }; } + private readonly IBindable accentColour = new Bindable(); + protected override void LoadComplete() { base.LoadComplete(); @@ -69,6 +74,12 @@ protected override void LoadComplete() { parentObject.ApplyCustomUpdateState += updateStateTransforms; updateStateTransforms(parentObject, parentObject.State.Value); + + if (skin.GetConfig(SkinConfiguration.LegacySetting.AllowSliderBallTint)?.Value == true) + { + accentColour.BindTo(parentObject.AccentColour); + accentColour.BindValueChanged(a => animationContent.Colour = a.NewValue, true); + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs new file mode 100644 index 000000000000..c9c7e86e8657 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public class LegacySmokeSegment : SmokeSegment + { + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + base.LoadComplete(); + + Texture = skin.GetTexture("cursor-smoke"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index 004222ad7a50..a817e5f2b722 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -65,6 +65,7 @@ private void load(DrawableHitObject drawableHitObject, ISkinSource source) { spin = new Sprite { + Alpha = 0, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Texture = source.GetTexture("spinner-spin"), @@ -82,7 +83,7 @@ private void load(DrawableHitObject drawableHitObject, ISkinSource source) }, bonusCounter = new LegacySpriteText(LegacyFont.Score) { - Alpha = 0f, + Alpha = 0, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Scale = new Vector2(SPRITE_SCALE), @@ -179,6 +180,9 @@ protected virtual void UpdateStateTransforms(DrawableHitObject drawableHitObject spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out); } + using (BeginAbsoluteSequence(d.HitObject.StartTime - d.HitObject.TimeFadeIn / 2)) + spin.FadeInFromZero(d.HitObject.TimeFadeIn / 2); + using (BeginAbsoluteSequence(d.HitObject.StartTime)) ApproachCircle?.ScaleTo(SPRITE_SCALE * 0.1f, d.HitObject.Duration); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 885a2c12fb19..856ccb504436 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -13,6 +13,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class OsuLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasHitCircle.Value; + private readonly Lazy hasHitCircle; /// @@ -106,6 +108,12 @@ public override Drawable GetDrawableComponent(ISkinComponent component) return null; + case OsuSkinComponents.CursorSmoke: + if (GetTexture("cursor-smoke") != null) + return new LegacySmokeSegment(); + + return null; + case OsuSkinComponents.HitCircleText: if (!this.HasFont(LegacyFont.HitCircle)) return null; diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 306a1e38b9e6..1c0a62454b59 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -9,7 +9,6 @@ public enum OsuSkinConfiguration { SliderBorderSize, SliderPathRadius, - AllowSliderBallTint, CursorCentre, CursorExpand, CursorRotate, diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs new file mode 100644 index 000000000000..46c8e7c02a73 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -0,0 +1,380 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public abstract class SmokeSegment : Drawable, ITexturedShaderDrawable + { + // fade anim values + private const double initial_fade_out_duration = 4000; + + private const double re_fade_in_speed = 3; + private const double re_fade_in_duration = 50; + + private const double final_fade_out_speed = 2; + private const double final_fade_out_duration = 8000; + + private const float initial_alpha = 0.6f; + private const float re_fade_in_alpha = 1f; + + private readonly int rotationSeed = RNG.Next(); + + // scale anim values + private const double scale_duration = 1200; + + private const float initial_scale = 0.65f; + private const float final_scale = 1f; + + // rotation anim values + private const double rotation_duration = 500; + + private const float max_rotation = 0.25f; + + public IShader? TextureShader { get; private set; } + public IShader? RoundedTextureShader { get; private set; } + + protected Texture? Texture { get; set; } + + private float radius => Texture?.DisplayWidth * 0.165f ?? 3; + + protected readonly List SmokePoints = new List(); + + private float pointInterval => radius * 7f / 8; + + private double smokeStartTime { get; set; } = double.MinValue; + + private double smokeEndTime { get; set; } = double.MaxValue; + + private float totalDistance; + private Vector2? lastPosition; + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RelativeSizeAxes = Axes.Both; + + LifetimeStart = smokeStartTime = Time.Current; + + totalDistance = pointInterval; + } + + public void AddPosition(Vector2 position, double time) + { + lastPosition ??= position; + + float delta = (position - (Vector2)lastPosition).LengthFast; + totalDistance += delta; + int count = (int)(totalDistance / pointInterval); + + if (count > 0) + { + Vector2 increment = position - (Vector2)lastPosition; + increment.NormalizeFast(); + + Vector2 pointPos = (pointInterval - (totalDistance - delta)) * increment + (Vector2)lastPosition; + increment *= pointInterval; + + totalDistance %= pointInterval; + + if (SmokePoints.Count == 0 || SmokePoints[^1].Time <= time) + { + for (int i = 0; i < count; i++) + { + SmokePoints.Add(new SmokePoint + { + Position = pointPos, + Time = time, + Angle = RNG.NextSingle(0, 2 * MathF.PI), + }); + + pointPos += increment; + } + } + + Invalidate(Invalidation.DrawNode); + } + + lastPosition = position; + } + + public void FinishDrawing(double time) + { + smokeEndTime = time; + + double initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, smokeEndTime - smokeStartTime); + LifetimeEnd = smokeEndTime + final_fade_out_duration + initialFadeOutDurationTrunc / re_fade_in_speed + initialFadeOutDurationTrunc / final_fade_out_speed; + } + + protected override DrawNode CreateDrawNode() => new SmokeDrawNode(this); + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + } + + protected struct SmokePoint + { + public Vector2 Position; + public double Time; + public float Angle; + + public struct UpperBoundComparer : IComparer + { + public int Compare(SmokePoint x, SmokePoint target) + { + // By returning -1 when the target value is equal to x, guarantees that the + // element at BinarySearch's returned index will always be the first element + // larger. Since 0 is never returned, the target is never "found", so the return + // value will be the index's complement. + + return x.Time > target.Time ? 1 : -1; + } + } + + public struct LowerBoundComparer : IComparer + { + public int Compare(SmokePoint x, SmokePoint target) + { + // Similar logic as UpperBoundComparer, except returned index will always be + // the first element larger or equal + + return x.Time < target.Time ? -1 : 1; + } + } + } + + protected class SmokeDrawNode : TexturedShaderDrawNode + { + protected new SmokeSegment Source => (SmokeSegment)base.Source; + + protected double SmokeStartTime { get; private set; } + protected double SmokeEndTime { get; private set; } + protected double CurrentTime { get; private set; } + + private readonly List points = new List(); + private IVertexBatch? quadBatch; + private float radius; + private Vector2 drawSize; + private Texture? texture; + private int rotationSeed; + private int firstVisiblePointIndex; + + // anim calculation vars (color, scale, direction) + private double initialFadeOutDurationTrunc; + private double firstVisiblePointTimeAfterSmokeEnded; + + private double initialFadeOutTime; + private double reFadeInTime; + private double finalFadeOutTime; + + public SmokeDrawNode(ITexturedShaderDrawable source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + radius = Source.radius; + drawSize = Source.DrawSize; + texture = Source.Texture; + + SmokeStartTime = Source.smokeStartTime; + SmokeEndTime = Source.smokeEndTime; + CurrentTime = Source.Clock.CurrentTime; + + rotationSeed = Source.rotationSeed; + + initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, SmokeEndTime - SmokeStartTime); + firstVisiblePointTimeAfterSmokeEnded = SmokeEndTime - initialFadeOutDurationTrunc; + + initialFadeOutTime = Math.Min(CurrentTime, SmokeEndTime); + reFadeInTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / re_fade_in_speed); + finalFadeOutTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / final_fade_out_speed); + + double firstVisiblePointTime = Math.Min(SmokeEndTime, CurrentTime) - initialFadeOutDurationTrunc; + firstVisiblePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = firstVisiblePointTime }, new SmokePoint.LowerBoundComparer()); + int futurePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = CurrentTime }, new SmokePoint.UpperBoundComparer()); + + points.Clear(); + points.AddRange(Source.SmokePoints.Skip(firstVisiblePointIndex).Take(futurePointIndex - firstVisiblePointIndex)); + } + + public sealed override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (points.Count == 0) + return; + + quadBatch ??= renderer.CreateQuadBatch(200, 4); + + if (points.Count > quadBatch.Size && quadBatch.Size != IRenderer.MAX_QUADS) + { + int batchSize = Math.Min(quadBatch.Size * 2, IRenderer.MAX_QUADS); + quadBatch = renderer.CreateQuadBatch(batchSize, 4); + } + + texture ??= renderer.WhitePixel; + RectangleF textureRect = texture.GetTextureRect(); + + var shader = GetAppropriateShader(renderer); + + renderer.SetBlend(BlendingParameters.Additive); + renderer.PushLocalMatrix(DrawInfo.Matrix); + + shader.Bind(); + texture.Bind(); + + for (int i = 0; i < points.Count; i++) + drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex); + + shader.Unbind(); + renderer.PopLocalMatrix(); + } + + protected Color4 ColourAtPosition(Vector2 localPos) => DrawColourInfo.Colour.HasSingleColour + ? ((SRGBColour)DrawColourInfo.Colour).Linear + : DrawColourInfo.Colour.Interpolate(Vector2.Divide(localPos, drawSize)).Linear; + + protected virtual Color4 PointColour(SmokePoint point) + { + var color = Color4.White; + + double timeDoingFinalFadeOut = finalFadeOutTime - point.Time / final_fade_out_speed; + + if (timeDoingFinalFadeOut > 0 && point.Time >= firstVisiblePointTimeAfterSmokeEnded) + { + float fraction = Math.Clamp((float)(timeDoingFinalFadeOut / final_fade_out_duration), 0, 1); + fraction = MathF.Pow(fraction, 5); + color.A = (1 - fraction) * re_fade_in_alpha; + } + else + { + double timeDoingInitialFadeOut = initialFadeOutTime - point.Time; + + if (timeDoingInitialFadeOut > 0) + { + float fraction = Math.Clamp((float)(timeDoingInitialFadeOut / initial_fade_out_duration), 0, 1); + color.A = (1 - fraction) * initial_alpha; + } + + if (point.Time > firstVisiblePointTimeAfterSmokeEnded) + { + double timeDoingReFadeIn = reFadeInTime - point.Time / re_fade_in_speed; + + if (timeDoingReFadeIn > 0) + { + float fraction = Math.Clamp((float)(timeDoingReFadeIn / re_fade_in_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + color.A = fraction * (re_fade_in_alpha - color.A) + color.A; + } + } + } + + return color; + } + + protected virtual float PointScale(SmokePoint point) + { + double timeDoingScale = CurrentTime - point.Time; + float fraction = Math.Clamp((float)(timeDoingScale / scale_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + return fraction * (final_scale - initial_scale) + initial_scale; + } + + protected virtual Vector2 PointDirection(SmokePoint point, int index) + { + double timeDoingRotation = CurrentTime - point.Time; + float fraction = Math.Clamp((float)(timeDoingRotation / rotation_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + float angle = fraction * getRotation(index) + point.Angle; + + return new Vector2(MathF.Sin(angle), -MathF.Cos(angle)); + } + + private float getRotation(int index) => max_rotation * (StatelessRNG.NextSingle(rotationSeed, index) * 2 - 1); + + private void drawPointQuad(SmokePoint point, RectangleF textureRect, int index) + { + Debug.Assert(quadBatch != null); + + var colour = PointColour(point); + if (colour.A == 0) + return; + + float scale = PointScale(point); + if (scale == 0) + return; + + var dir = PointDirection(point, index); + var ortho = dir.PerpendicularLeft; + + var localTopLeft = point.Position + (radius * scale * (-ortho - dir)); + var localTopRight = point.Position + (radius * scale * (-ortho + dir)); + var localBotLeft = point.Position + (radius * scale * (ortho - dir)); + var localBotRight = point.Position + (radius * scale * (ortho + dir)); + + quadBatch.Add(new TexturedVertex2D + { + Position = localTopLeft, + TexturePosition = textureRect.TopLeft, + Colour = Color4Extensions.Multiply(ColourAtPosition(localTopLeft), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localTopRight, + TexturePosition = textureRect.TopRight, + Colour = Color4Extensions.Multiply(ColourAtPosition(localTopRight), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localBotRight, + TexturePosition = textureRect.BottomRight, + Colour = Color4Extensions.Multiply(ColourAtPosition(localBotRight), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localBotLeft, + TexturePosition = textureRect.BottomLeft, + Colour = Color4Extensions.Multiply(ColourAtPosition(localBotLeft), colour), + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + quadBatch?.Dispose(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index fc2ba8ea2fcf..e9a6c84c0b4d 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -36,6 +36,7 @@ public class OsuPlayfield : Playfield private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; + public SmokeContainer Smoke { get; } public FollowPointRenderer FollowPoints { get; } public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); @@ -54,6 +55,7 @@ public OsuPlayfield() InternalChildren = new Drawable[] { playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + Smoke = new SmokeContainer { RelativeSizeAxes = Axes.Both }, spinnerProxies = new ProxyContainer { RelativeSizeAxes = Axes.Both }, FollowPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }, judgementLayer = new JudgementContainer { RelativeSizeAxes = Axes.Both }, diff --git a/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs new file mode 100644 index 000000000000..beba834e8899 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs @@ -0,0 +1,77 @@ +// 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.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Manages smoke trails generated from user input. + /// + public class SmokeContainer : Container, IRequireHighFrequencyMousePosition, IKeyBindingHandler + { + private SmokeSkinnableDrawable? currentSegmentSkinnable; + + private Vector2 lastMousePosition; + + public override bool ReceivePositionalInputAt(Vector2 _) => true; + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == OsuAction.Smoke) + { + AddInternal(currentSegmentSkinnable = new SmokeSkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment())); + + // Add initial position immediately. + addPosition(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == OsuAction.Smoke) + { + if (currentSegmentSkinnable?.Drawable is SmokeSegment segment) + { + segment.FinishDrawing(Time.Current); + currentSegmentSkinnable = null; + } + } + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + lastMousePosition = e.MousePosition; + addPosition(); + + return base.OnMouseMove(e); + } + + private void addPosition() => (currentSegmentSkinnable?.Drawable as SmokeSegment)?.AddPosition(lastMousePosition, Time.Current); + + private class SmokeSkinnableDrawable : SkinnableDrawable + { + public override bool RemoveWhenNotAlive => true; + + public override double LifetimeStart => Drawable.LifetimeStart; + public override double LifetimeEnd => Drawable.LifetimeEnd; + + public SmokeSkinnableDrawable(ISkinComponent component, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(component, defaultImplementation, confineMode) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs new file mode 100644 index 000000000000..d55ce17e6cea --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public class TestSceneTaikoModFlashlight : TaikoModTestScene + { + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.25f)] + [TestCase(1.5f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new TaikoModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new TaikoModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + + [Test] + public void TestFlashlightAlwaysHasNonZeroSize() + { + bool failed = false; + + CreateModTest(new ModTestData + { + Mod = new TestTaikoModFlashlight { ComboBasedSize = { Value = true } }, + Autoplay = false, + PassCondition = () => + { + failed |= this.ChildrenOfType().SingleOrDefault()?.FlashlightSize.Y == 0; + return !failed; + } + }); + } + + private class TestTaikoModFlashlight : TaikoModFlashlight + { + protected override Flashlight CreateFlashlight() => new TestTaikoFlashlight(this, Playfield); + + public class TestTaikoFlashlight : TaikoFlashlight + { + public TestTaikoFlashlight(TaikoModFlashlight modFlashlight, TaikoPlayfield taikoPlayfield) + : base(modFlashlight, taikoPlayfield) + { + } + + public new Vector2 FlashlightSize => base.FlashlightSize; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs index 2d27e0e40e23..e42dc254accd 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs @@ -25,8 +25,8 @@ public class TestSceneDrawableDrumRoll : TaikoSkinnableTestScene TimeRange = { Value = 5000 }, }; - [BackgroundDependencyLoader] - private void load() + [Test] + public void DrumrollTest() { AddStep("Drum roll", () => SetContents(_ => { diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRollKiai.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRollKiai.cs new file mode 100644 index 000000000000..53977150e7b3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRollKiai.cs @@ -0,0 +1,30 @@ +// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableDrumRollKiai : TestSceneDrawableDrumRoll + { + [SetUp] + public void SetUp() => Schedule(() => + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + // track needs to be playing for BeatSyncedContainer to work. + Beatmap.Value.Track.Start(); + }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs index d5a97f8f88f0..8e9c487c2f26 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs @@ -4,7 +4,6 @@ #nullable disable using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -16,8 +15,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestFixture] public class TestSceneDrawableHit : TaikoSkinnableTestScene { - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestHits() { AddStep("Centre hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime()) { diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHitKiai.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHitKiai.cs new file mode 100644 index 000000000000..fac0530749c4 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHitKiai.cs @@ -0,0 +1,30 @@ +// 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.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableHitKiai : TestSceneDrawableHit + { + [SetUp] + public void SetUp() => Schedule(() => + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + // track needs to be playing for BeatSyncedContainer to work. + Beatmap.Value.Track.Start(); + }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs new file mode 100644 index 000000000000..095fddc33fc1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneBarLineGeneration : OsuTestScene + { + [Test] + public void TestCloseBarLineGeneration() + { + const double start_time = 1000; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = start_time + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(start_time, new TimingControlPoint()); + beatmap.ControlPointInfo.Add(start_time + 1, new TimingControlPoint()); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("first barline generated", () => barlines.Any(b => b.StartTime == start_time)); + AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + 1)); + } + + [Test] + public void TestOmitBarLineEffectPoint() + { + const double start_time = 1000; + const double beat_length = 500; + + const int time_signature_numerator = 4; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = start_time + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(start_time, new TimingControlPoint + { + BeatLength = beat_length, + TimeSignature = new TimeSignature(time_signature_numerator) + }); + + beatmap.ControlPointInfo.Add(start_time, new EffectControlPoint { OmitFirstBarLine = true }); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("first barline ommited", () => barlines.All(b => b.StartTime != start_time)); + AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + (beat_length * time_signature_numerator))); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index e8eaff4368ec..38e61f562420 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -1,9 +1,9 @@  - + - + WinExe diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index b9a9dacddc2f..4839a8964634 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -20,6 +20,7 @@ public class TaikoPerformanceCalculator : PerformanceCalculator private int countOk; private int countMeh; private int countMiss; + private double accuracy; private double effectiveMissCount; @@ -36,6 +37,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + accuracy = customAccuracy; // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. if (totalSuccessfulHits > 0) @@ -90,7 +92,7 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes if (score.Mods.Any(m => m is ModFlashlight)) difficultyValue *= 1.050 * lengthBonus; - return difficultyValue * Math.Pow(score.Accuracy, 2.0); + return difficultyValue * Math.Pow(accuracy, 2.0); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool rulesetTaiko) @@ -98,7 +100,7 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a if (attributes.GreatHitWindow <= 0) return 0; - double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; + double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); accuracyValue *= lengthBonus; @@ -113,5 +115,7 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; + + private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index df1450bf779b..863a2c9eac72 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -27,6 +27,12 @@ public HitPlacementBlueprint() }; } + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + protected override bool OnMouseDown(MouseDownEvent e) { switch (e.Button) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index 23a005190a81..70364cabf11c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -52,6 +52,12 @@ public TaikoSpanPlacementBlueprint(HitObject hitObject) private double originalStartTime; private Vector2 originalPosition; + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button != MouseButton.Left) diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs index 1c1a5c325fda..161799c98087 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index 1caacdd1d7cc..46569c249548 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -25,19 +25,19 @@ public class TaikoModFlashlight : ModFlashlight public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 250; + public override float DefaultFlashlightSize => 200; - protected override Flashlight CreateFlashlight() => new TaikoFlashlight(this, playfield); + protected override Flashlight CreateFlashlight() => new TaikoFlashlight(this, Playfield); - private TaikoPlayfield playfield = null!; + protected TaikoPlayfield Playfield { get; private set; } = null!; public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - playfield = (TaikoPlayfield)drawableRuleset.Playfield; + Playfield = (TaikoPlayfield)drawableRuleset.Playfield; base.ApplyToDrawableRuleset(drawableRuleset); } - private class TaikoFlashlight : Flashlight + public class TaikoFlashlight : Flashlight { private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); private readonly TaikoPlayfield taikoPlayfield; @@ -46,20 +46,29 @@ public TaikoFlashlight(TaikoModFlashlight modFlashlight, TaikoPlayfield taikoPla : base(modFlashlight) { this.taikoPlayfield = taikoPlayfield; - FlashlightSize = getSizeFor(0); + + FlashlightSize = adjustSizeForPlayfieldAspectRatio(GetSize()); + FlashlightSmoothness = 1.4f; AddLayout(flashlightProperties); } - private Vector2 getSizeFor(int combo) + /// + /// Returns the aspect ratio-adjusted size of the flashlight. + /// This ensures that the size of the flashlight remains independent of taiko-specific aspect ratio adjustments. + /// + /// + /// The size of the flashlight. + /// The value provided here should always come from . + /// + private Vector2 adjustSizeForPlayfieldAspectRatio(float size) { - // Preserve flashlight size through the playfield's aspect adjustment. - return new Vector2(0, GetSizeFor(combo) * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); + return new Vector2(0, size * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), getSizeFor(e.NewValue), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), adjustSizeForPlayfieldAspectRatio(size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; @@ -73,7 +82,7 @@ protected override void Update() FlashlightPosition = ToLocalSpace(taikoPlayfield.HitTarget.ScreenSpaceDrawQuad.Centre); ClearTransforms(targetMember: nameof(FlashlightSize)); - FlashlightSize = getSizeFor(Combo.Value); + FlashlightSize = adjustSizeForPlayfieldAspectRatio(GetSize()); flashlightProperties.Validate(); } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs index a7ab1bcd4aa8..6b5a9ae6d24f 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,6 +14,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; using osuTK.Graphics; @@ -32,6 +34,8 @@ public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour private const double pre_beat_transition_time = 80; + private const float flash_opacity = 0.3f; + private Color4 accentColour; /// @@ -152,11 +156,22 @@ private void resetEdgeEffects() }; } + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { if (!effectPoint.KiaiMode) return; + if (drawableHitObject.State.Value == ArmedState.Idle) + { + FlashBox + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); + } + if (beatIndex % timingPoint.TimeSignature.Numerator != 0) return; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs index 399bd9260d40..6bbeb0ed4c89 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs @@ -45,7 +45,7 @@ Drawable getDrawableFor(string lookup) } // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. - AddInternal(backgroundLayer = getDrawableFor("circle")); + AddInternal(backgroundLayer = new LegacyKiaiFlashingDrawable(() => getDrawableFor("circle"))); var foregroundLayer = getDrawableFor("circleoverlay"); if (foregroundLayer != null) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index 992316ca53f7..020cdab4dc82 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -14,8 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public class TaikoLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasHitCircle || hasBarLeft; + private readonly Lazy hasExplosion; + private bool hasHitCircle => GetTexture("taikohitcircle") != null; + private bool hasBarLeft => GetTexture("taiko-bar-left") != null; + public TaikoLegacySkinTransformer(ISkin skin) : base(skin) { @@ -42,14 +47,14 @@ public TaikoLegacySkinTransformer(ISkin skin) return null; case TaikoSkinComponents.InputDrum: - if (GetTexture("taiko-bar-left") != null) + if (hasBarLeft) return new LegacyInputDrum(); return null; case TaikoSkinComponents.CentreHit: case TaikoSkinComponents.RimHit: - if (GetTexture("taikohitcircle") != null) + if (hasHitCircle) return new LegacyHit(taikoComponent.Component); return null; diff --git a/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs index f14288e7ba40..4ef466efbfca 100644 --- a/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs +++ b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs @@ -9,8 +9,10 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; @@ -96,5 +98,41 @@ public void TestForcedRefetchRetrievalWithFiles() => AddStep("run test", () => var second = beatmaps.GetWorkingBeatmap(beatmap, true); Assert.That(first, Is.Not.SameAs(second)); }); + + [Test] + public void TestSavePreservesCollections() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + var working = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(working.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + string initialHash = working.BeatmapInfo.MD5Hash; + + var preserveCollection = new BeatmapCollection("test contained"); + preserveCollection.BeatmapMD5Hashes.Add(initialHash); + + var noNewCollection = new BeatmapCollection("test not contained"); + + Realm.Write(r => + { + r.Add(preserveCollection); + r.Add(noNewCollection); + }); + + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Contain(initialHash)); + Assert.That(noNewCollection.BeatmapMD5Hashes, Does.Not.Contain(initialHash)); + + beatmaps.Save(working.BeatmapInfo, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + + string finalHash = working.BeatmapInfo.MD5Hash; + + Assert.That(finalHash, Is.Not.SameAs(initialHash)); + + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Not.Contain(initialHash)); + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Contain(finalHash)); + Assert.That(noNewCollection.BeatmapMD5Hashes, Does.Not.Contain(finalHash)); + }); } } diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 1e87ed27df1a..a98f931e7a3a 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -71,9 +71,9 @@ public void TestSliderMultiplier(float multiplier) [TestCase(1)] [TestCase(2)] - public void TestSpeedMultiplier(float multiplier) + public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) { - assertSnapDistance(100 * multiplier, new HitObject + assertSnapDistance(100, new HitObject { DifficultyControlPoint = new DifficultyControlPoint { diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 3f20f843a7e0..e7590df3e066 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -8,7 +8,6 @@ using System.Diagnostics; using System.IO; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; @@ -78,7 +77,7 @@ public void SetUp() => Schedule(() => } }; - beatmaps.AllowImport = new TaskCompletionSource(); + beatmaps.AllowImport.Reset(); testBeatmapFile = TestResources.GetQuickTestBeatmapForImport(); @@ -132,7 +131,7 @@ public void TestBeatmapDownloadingFlow() AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.TriggerSuccess(testBeatmapFile)); addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing); - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddUntilStep("wait for import", () => beatmaps.CurrentImport != null); AddUntilStep("ensure beatmap available", () => beatmaps.IsAvailableLocally(testBeatmapSet)); addAvailabilityCheckStep("state is locally available", BeatmapAvailability.LocallyAvailable); @@ -141,7 +140,7 @@ public void TestBeatmapDownloadingFlow() [Test] public void TestTrackerRespectsSoftDeleting() { - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); @@ -155,7 +154,7 @@ public void TestTrackerRespectsSoftDeleting() [Test] public void TestTrackerRespectsChecksum() { - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("initially locally available", BeatmapAvailability.LocallyAvailable); @@ -202,7 +201,7 @@ private static BeatmapInfo getTestBeatmapInfo(string archiveFile) private class TestBeatmapManager : BeatmapManager { - public TaskCompletionSource AllowImport = new TaskCompletionSource(); + public readonly ManualResetEventSlim AllowImport = new ManualResetEventSlim(); public Live CurrentImport { get; private set; } @@ -229,7 +228,9 @@ public TestBeatmapImporter(TestBeatmapManager testBeatmapManager, Storage storag public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) { - testBeatmapManager.AllowImport.Task.WaitSafely(); + if (!testBeatmapManager.AllowImport.Wait(TimeSpan.FromSeconds(10), cancellationToken)) + throw new TimeoutException("Timeout waiting for import to be allowed."); + return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, batchImport, cancellationToken)); } } diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk b/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk new file mode 100644 index 000000000000..74ff4f31d5c5 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk differ diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 1b03f8ef6bea..989459632e88 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -38,7 +38,9 @@ public class SkinDeserialisationTest // Covers legacy song progress, UR counter, colour hit error metre. "Archives/modified-classic-20220801.osk", // Covers clicks/s counter - "Archives/modified-default-20220818.osk" + "Archives/modified-default-20220818.osk", + // Covers longest combo counter + "Archives/modified-default-20221012.osk" }; /// diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 5aadd6f56a3b..917434ae22a8 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -244,7 +244,10 @@ public void TestTransition() public void TestResumeFromPlayer() { performFullSetup(); - AddStep("Move mouse to Visual Settings", () => InputManager.MoveMouseTo(playerLoader.VisualSettingsPos)); + AddStep("Move mouse to Visual Settings location", () => InputManager.MoveMouseTo(playerLoader.ScreenSpaceDrawQuad.TopRight + + new Vector2(-playerLoader.VisualSettingsPos.ScreenSpaceDrawQuad.Width, + playerLoader.VisualSettingsPos.ScreenSpaceDrawQuad.Height / 2 + ))); AddStep("Resume PlayerLoader", () => player.Restart()); AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs index ecd77328622a..f2d27b9117d5 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs @@ -106,6 +106,49 @@ public void TestBeatChevronNavigation() assertBeatSnap(16); } + [Test] + public void TestKeyboardNavigation() + { + pressKey(1); + assertBeatSnap(1); + assertPreset(BeatDivisorType.Common); + + pressKey(2); + assertBeatSnap(2); + assertPreset(BeatDivisorType.Common); + + pressKey(3); + assertBeatSnap(3); + assertPreset(BeatDivisorType.Triplets); + + pressKey(4); + assertBeatSnap(4); + assertPreset(BeatDivisorType.Common); + + pressKey(5); + assertBeatSnap(5); + assertPreset(BeatDivisorType.Custom, 5); + + pressKey(6); + assertBeatSnap(6); + assertPreset(BeatDivisorType.Triplets); + + pressKey(7); + assertBeatSnap(7); + assertPreset(BeatDivisorType.Custom, 7); + + pressKey(8); + assertBeatSnap(8); + assertPreset(BeatDivisorType.Common); + + void pressKey(int key) => AddStep($"press shift+{key}", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Number0 + key); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + } + [Test] public void TestBeatPresetNavigation() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBindings.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBindings.cs new file mode 100644 index 000000000000..5771d64775aa --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBindings.cs @@ -0,0 +1,32 @@ +// 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.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + /// + /// Test editor hotkeys at a high level to ensure they all work well together. + /// + public class TestSceneEditorBindings : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestBeatDivisorChangeHotkeys() + { + AddStep("hold shift", () => InputManager.PressKey(Key.LShift)); + + AddStep("press 4", () => InputManager.Key(Key.Number4)); + AddAssert("snap updated to 4", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(4)); + + AddStep("press 6", () => InputManager.Key(Key.Number6)); + AddAssert("snap updated to 6", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(6)); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.LShift)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index 23e137865c7d..0a5a1febe419 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -155,6 +155,20 @@ public void TestCopyPaste(bool deselectAfterCopy) AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); } + [Test] + public void TestClone() + { + var addedObject = new HitCircle { StartTime = 1000 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + AddStep("clone", () => Editor.Clone()); + AddAssert("is two objects", () => EditorBeatmap.HitObjects.Count == 2); + AddStep("clone", () => Editor.Clone()); + AddAssert("is three objects", () => EditorBeatmap.HitObjects.Count == 3); + } + [Test] public void TestCutNothing() { @@ -175,5 +189,22 @@ public void TestPasteNothing() AddStep("paste hitobject", () => Editor.Paste()); AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0); } + + [Test] + public void TestCloneNothing() + { + // Add arbitrary object and copy to clipboard. + // This is tested to ensure that clone doesn't incorrectly read from the clipboard when no selection is made. + var addedObject = new HitCircle { StartTime = 1000 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + AddStep("copy hitobject", () => Editor.Copy()); + + AddStep("deselect all objects", () => EditorBeatmap.SelectedHitObjects.Clear()); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + AddStep("clone", () => Editor.Clone()); + AddAssert("still one object", () => EditorBeatmap.HitObjects.Count == 1); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 56435c69a4a4..6a6934765196 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -4,8 +4,10 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Screens.Edit.Components.RadioButtons; namespace osu.Game.Tests.Visual.Editing @@ -13,6 +15,9 @@ namespace osu.Game.Tests.Visual.Editing [TestFixture] public class TestSceneEditorComposeRadioButtons : OsuTestScene { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + public TestSceneEditorComposeRadioButtons() { EditorRadioButtonCollection collection; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index adb495f3d386..1c87eb49c998 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -148,10 +148,6 @@ public void TestPlacementWithinToolboxScrollArea() }); AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0); - - AddStep("place circle", () => InputManager.Click(MouseButton.Left)); - - AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1); } [Test] @@ -165,10 +161,11 @@ public void TestDistanceSpacingHotkeys() AddStep("hold alt", () => InputManager.PressKey(Key.LAlt)); AddStep("scroll mouse 5 steps", () => InputManager.ScrollVerticalBy(5)); - AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); AddStep("release alt", () => InputManager.ReleaseKey(Key.LAlt)); AddStep("release ctrl", () => InputManager.ReleaseKey(Key.LControl)); + + AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); } public class EditorBeatmapContainer : Container diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs index 7e0981ce692d..54ad4e25e4c3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -29,16 +29,18 @@ public class TestSceneTimelineSelection : EditorTestScene private TimelineBlueprintContainer blueprintContainer => Editor.ChildrenOfType().First(); + private Vector2 getPosition(HitObject hitObject) => + blueprintContainer.SelectionBlueprints.First(s => s.Item == hitObject).ScreenSpaceDrawQuad.Centre; + + private Vector2 getMiddlePosition(HitObject hitObject1, HitObject hitObject2) => + (getPosition(hitObject1) + getPosition(hitObject2)) / 2; + private void moveMouseToObject(Func targetFunc) { AddStep("move mouse to object", () => { - var pos = blueprintContainer.SelectionBlueprints - .First(s => s.Item == targetFunc()) - .ChildrenOfType() - .First().ScreenSpaceDrawQuad.Centre; - - InputManager.MoveMouseTo(pos); + var hitObject = targetFunc(); + InputManager.MoveMouseTo(getPosition(hitObject)); }); } @@ -262,6 +264,56 @@ public void TestRangeSelectAfterExternalSelection() AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); } + [Test] + public void TestBasicDragSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 500, Position = new Vector2(100) }, + new HitCircle { StartTime = 1000, Position = new Vector2(200) }, + new HitCircle { StartTime = 1500, Position = new Vector2(300) }, + }; + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[0], addedObjects[1]))); + AddStep("mouse down", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("drag to select", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[2], addedObjects[3]))); + assertSelectionIs(new[] { addedObjects[1], addedObjects[2] }); + + AddStep("drag to deselect", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[1], addedObjects[2]))); + assertSelectionIs(new[] { addedObjects[1] }); + + AddStep("mouse up", () => InputManager.ReleaseButton(MouseButton.Left)); + assertSelectionIs(new[] { addedObjects[1] }); + } + + [Test] + public void TestFastDragSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 20000, Position = new Vector2(100) }, + new HitCircle { StartTime = 31000, Position = new Vector2(200) }, + new HitCircle { StartTime = 60000, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[0], addedObjects[1]))); + AddStep("mouse down", () => InputManager.PressButton(MouseButton.Left)); + AddStep("start drag", () => InputManager.MoveMouseTo(getPosition(addedObjects[1]))); + + AddStep("jump editor clock", () => EditorClock.Seek(30000)); + AddStep("jump editor clock", () => EditorClock.Seek(60000)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1)); + AddAssert("all blueprints are present", () => blueprintContainer.SelectionBlueprints.Count == EditorBeatmap.SelectedHitObjects.Count); + } + private void assertSelectionIs(IEnumerable hitObjects) => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects)); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index 1858aee76bae..89c5b9b23b51 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -55,7 +55,7 @@ public void SetUpSteps() } } }, - new MenuCursor() + new MenuCursorContainer() }; scrollContainer.Add(innerBox = new Box diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs index 0a32513834ae..bd55ed8bd6de 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -31,7 +31,7 @@ public void TestNoJudgementsOnRewind() AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); addSeekStep(3000); AddAssert("all judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => h.Judged)); - AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses >= 7)); + AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.Select(kc => kc.CountPresses).Sum() == 15); AddStep("clear results", () => Player.Results.Clear()); addSeekStep(0); AddAssert("none judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => !h.Judged)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index da6604a653eb..a984f508ea13 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -16,6 +16,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; @@ -148,6 +149,42 @@ public void TestChangeHUDVisibilityOnHiddenKeyCounter() AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); } + [Test] + public void TestInputDoesntWorkWhenHUDHidden() + { + SongProgressBar getSongProgress() => hudOverlay.ChildrenOfType().Single(); + + bool seeked = false; + + createNew(); + + AddStep("bind seek", () => + { + seeked = false; + + var progress = getSongProgress(); + + progress.ShowHandle = true; + progress.OnSeek += _ => seeked = true; + }); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + + AddStep("attempt seek", () => + { + InputManager.MoveMouseTo(getSongProgress()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("seek not performed", () => !seeked); + + AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + + AddStep("attempt seek", () => InputManager.Click(MouseButton.Left)); + AddAssert("seek performed", () => seeked); + } + [Test] public void TestHiddenHUDDoesntBlockComponentUpdates() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs new file mode 100644 index 000000000000..f8b5085a7080 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs @@ -0,0 +1,498 @@ +// 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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneScoring : OsuTestScene + { + private GraphContainer graphs = null!; + private SettingsSlider sliderMaxCombo = null!; + + private FillFlowContainer legend = null!; + + [Test] + public void TestBasic() + { + AddStep("setup tests", () => + { + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + graphs = new GraphContainer + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Drawable[] + { + legend = new FillFlowContainer + { + Padding = new MarginPadding(20), + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }, + new Drawable[] + { + new FillFlowContainer + { + Padding = new MarginPadding(20), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Children = new Drawable[] + { + sliderMaxCombo = new SettingsSlider + { + Width = 0.5f, + TransferValueOnCommit = true, + Current = new BindableInt(1024) + { + MinValue = 96, + MaxValue = 8192, + }, + LabelText = "max combo", + }, + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + Width = 0.5f, + AutoSizeAxes = Axes.Y, + Text = $"Left click to add miss\nRight click to add OK/{base_ok}" + } + } + }, + }, + } + } + }; + + sliderMaxCombo.Current.BindValueChanged(_ => rerun()); + + graphs.MissLocations.BindCollectionChanged((_, __) => rerun()); + graphs.NonPerfectLocations.BindCollectionChanged((_, __) => rerun()); + + graphs.MaxCombo.BindTo(sliderMaxCombo.Current); + + rerun(); + }); + } + + private const int base_great = 300; + private const int base_ok = 100; + + private void rerun() + { + graphs.Clear(); + legend.Clear(); + + runForProcessor("lazer-standardised", Color4.YellowGreen, new ScoreProcessor(new OsuRuleset()) { Mode = { Value = ScoringMode.Standardised } }); + runForProcessor("lazer-classic", Color4.MediumPurple, new ScoreProcessor(new OsuRuleset()) { Mode = { Value = ScoringMode.Classic } }); + + runScoreV1(); + runScoreV2(); + } + + private void runScoreV1() + { + int totalScore = 0; + int currentCombo = 0; + + void applyHitV1(int baseScore) + { + if (baseScore == 0) + { + currentCombo = 0; + return; + } + + const float score_multiplier = 1; + + totalScore += baseScore; + + // combo multiplier + // ReSharper disable once PossibleLossOfFraction + totalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * score_multiplier)); + + currentCombo++; + } + + runForAlgorithm("ScoreV1 (classic)", Color4.Purple, + () => applyHitV1(base_great), + () => applyHitV1(base_ok), + () => applyHitV1(0), + () => + { + // Arbitrary value chosen towards the upper range. + const double score_multiplier = 4; + + return (int)(totalScore * score_multiplier); + }); + } + + private void runScoreV2() + { + int maxCombo = sliderMaxCombo.Current.Value; + + int currentCombo = 0; + double comboPortion = 0; + double currentBaseScore = 0; + double maxBaseScore = 0; + int currentHits = 0; + + for (int i = 0; i < maxCombo; i++) + applyHitV2(base_great); + + double comboPortionMax = comboPortion; + + currentCombo = 0; + comboPortion = 0; + currentBaseScore = 0; + maxBaseScore = 0; + currentHits = 0; + + void applyHitV2(int baseScore) + { + maxBaseScore += base_great; + currentBaseScore += baseScore; + comboPortion += baseScore * (1 + ++currentCombo / 10.0); + + currentHits++; + } + + runForAlgorithm("ScoreV2", Color4.OrangeRed, + () => applyHitV2(base_great), + () => applyHitV2(base_ok), + () => + { + currentHits++; + maxBaseScore += base_great; + currentCombo = 0; + }, () => + { + double accuracy = currentBaseScore / maxBaseScore; + + return (int)Math.Round + ( + 700000 * comboPortion / comboPortionMax + + 300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo) + ); + }); + } + + private void runForProcessor(string name, Color4 colour, ScoreProcessor processor) + { + int maxCombo = sliderMaxCombo.Current.Value; + + var beatmap = new OsuBeatmap(); + for (int i = 0; i < maxCombo; i++) + beatmap.HitObjects.Add(new HitCircle()); + + processor.ApplyBeatmap(beatmap); + + runForAlgorithm(name, colour, + () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great }), + () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok }), + () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss }), + () => (int)processor.TotalScore.Value); + } + + private void runForAlgorithm(string name, Color4 colour, Action applyHit, Action applyNonPerfect, Action applyMiss, Func getTotalScore) + { + int maxCombo = sliderMaxCombo.Current.Value; + + List results = new List(); + + for (int i = 0; i < maxCombo; i++) + { + if (graphs.MissLocations.Contains(i)) + applyMiss(); + else if (graphs.NonPerfectLocations.Contains(i)) + applyNonPerfect(); + else + applyHit(); + + results.Add(getTotalScore()); + } + + graphs.Add(new LineGraph + { + Name = name, + RelativeSizeAxes = Axes.Both, + LineColour = colour, + Values = results + }); + + legend.Add(new OsuSpriteText + { + Colour = colour, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Text = $"{FontAwesome.Solid.Circle.Icon} {name}" + }); + + legend.Add(new OsuSpriteText + { + Colour = colour, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Text = $"final score {getTotalScore():#,0}" + }); + } + } + + public class GraphContainer : Container, IHasCustomTooltip> + { + public readonly BindableList MissLocations = new BindableList(); + public readonly BindableList NonPerfectLocations = new BindableList(); + + public Bindable MaxCombo = new Bindable(); + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private readonly Box hoverLine; + + private readonly Container missLines; + private readonly Container verticalGridLines; + + public int CurrentHoverCombo { get; private set; } + + public GraphContainer() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + verticalGridLines = new Container + { + RelativeSizeAxes = Axes.Both, + }, + hoverLine = new Box + { + Colour = Color4.Yellow, + RelativeSizeAxes = Axes.Y, + Origin = Anchor.TopCentre, + Alpha = 0, + Width = 1, + }, + missLines = new Container + { + Alpha = 0.6f, + RelativeSizeAxes = Axes.Both, + }, + Content, + } + }; + + MissLocations.BindCollectionChanged((_, _) => updateMissLocations()); + NonPerfectLocations.BindCollectionChanged((_, _) => updateMissLocations()); + + MaxCombo.BindValueChanged(_ => + { + updateMissLocations(); + updateVerticalGridLines(); + }, true); + } + + private void updateVerticalGridLines() + { + verticalGridLines.Clear(); + + for (int i = 0; i < MaxCombo.Value; i++) + { + if (i % 100 == 0) + { + verticalGridLines.AddRange(new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)i / MaxCombo.Value, + }, + new OsuSpriteText + { + RelativePositionAxes = Axes.X, + X = (float)i / MaxCombo.Value, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = $"{i:#,0}", + Rotation = -30, + Y = -20, + } + }); + } + } + } + + private void updateMissLocations() + { + missLines.Clear(); + + foreach (int miss in MissLocations) + { + missLines.Add(new Box + { + Colour = Color4.Red, + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)miss / MaxCombo.Value, + }); + } + + foreach (int miss in NonPerfectLocations) + { + missLines.Add(new Box + { + Colour = Color4.Orange, + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)miss / MaxCombo.Value, + }); + } + } + + protected override bool OnHover(HoverEvent e) + { + hoverLine.Show(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLine.Hide(); + base.OnHoverLost(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + CurrentHoverCombo = (int)(e.MousePosition.X / DrawWidth * MaxCombo.Value); + + hoverLine.X = e.MousePosition.X; + return base.OnMouseMove(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + MissLocations.Add(CurrentHoverCombo); + else + NonPerfectLocations.Add(CurrentHoverCombo); + + return true; + } + + private GraphTooltip? tooltip; + + public ITooltip> GetCustomTooltip() => tooltip ??= new GraphTooltip(this); + + public IEnumerable TooltipContent => Content.OfType(); + + public class GraphTooltip : CompositeDrawable, ITooltip> + { + private readonly GraphContainer graphContainer; + + private readonly OsuTextFlowContainer textFlow; + + public GraphTooltip(GraphContainer graphContainer) + { + this.graphContainer = graphContainer; + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 10; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.15f), + RelativeSizeAxes = Axes.Both, + }, + textFlow = new OsuTextFlowContainer + { + Colour = Color4.White, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + } + }; + } + + private int? lastContentCombo; + + public void SetContent(IEnumerable content) + { + int relevantCombo = graphContainer.CurrentHoverCombo; + + if (lastContentCombo == relevantCombo) + return; + + lastContentCombo = relevantCombo; + textFlow.Clear(); + + textFlow.AddParagraph($"At combo {relevantCombo}:"); + + foreach (var graph in content) + { + float valueAtHover = graph.Values.ElementAt(relevantCombo); + float ofTotal = valueAtHover / graph.Values.Last(); + + textFlow.AddParagraph($"{graph.Name}: {valueAtHover:#,0} ({ofTotal * 100:N0}% of final)\n", st => st.Colour = graph.LineColour); + } + } + + public void Move(Vector2 pos) => this.MoveTo(pos); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index 8a4818d2f81d..6d036f8e9bc8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -11,8 +11,10 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; @@ -167,14 +169,39 @@ public void TestOverlappingScrollLifetime() AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); } - private void addHitObject(double time) + [Test] + public void TestVeryFlowScroll() + { + const double long_time_range = 100000; + var manualClock = new ManualClock(); + + AddStep("set manual clock", () => + { + manualClock.CurrentTime = 0; + scrollContainers.ForEach(c => c.Clock = new FramedClock(manualClock)); + + setScrollAlgorithm(ScrollVisualisationMethod.Constant); + scrollContainers.ForEach(c => c.TimeRange = long_time_range); + }); + + AddStep("add hit objects", () => + { + addHitObject(long_time_range); + addHitObject(long_time_range + 100, 250); + }); + + AddAssert("hit objects are alive", () => playfields.All(p => p.HitObjectContainer.AliveObjects.Count() == 2)); + } + + private void addHitObject(double time, float size = 75) { playfields.ForEach(p => { - var hitObject = new TestDrawableHitObject(time); - setAnchor(hitObject, p); + var hitObject = new TestHitObject(size) { StartTime = time }; + var drawable = new TestDrawableHitObject(hitObject); - p.Add(hitObject); + setAnchor(drawable, p); + p.Add(drawable); }); } @@ -187,7 +214,7 @@ private TestDrawableControlPoint createDrawablePoint(TestPlayfield playfield, do private void addControlPoints(IList controlPoints, double sequenceStartTime) { - controlPoints.ForEach(point => point.StartTime += sequenceStartTime); + controlPoints.ForEach(point => point.Time += sequenceStartTime); scrollContainers.ForEach(container => { @@ -197,7 +224,7 @@ private void addControlPoints(IList controlPoints, doubl foreach (var playfield in playfields) { foreach (var controlPoint in controlPoints) - playfield.Add(createDrawablePoint(playfield, controlPoint.StartTime)); + playfield.Add(createDrawablePoint(playfield, controlPoint.Time)); } } @@ -248,6 +275,8 @@ public TestPlayfield() } }; } + + protected override ScrollingHitObjectContainer CreateScrollingHitObjectContainer() => new TestScrollingHitObjectContainer(); } private class TestDrawableControlPoint : DrawableHitObject @@ -281,22 +310,41 @@ public TestDrawableControlPoint(ScrollingDirection direction, double time) } } - private class TestDrawableHitObject : DrawableHitObject + private class TestHitObject : HitObject { - public TestDrawableHitObject(double time) - : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) + public readonly float Size; + + public TestHitObject(float size) { - Origin = Anchor.Custom; - OriginPosition = new Vector2(75 / 4.0f); + Size = size; + } + } - AutoSizeAxes = Axes.Both; + private class TestDrawableHitObject : DrawableHitObject + { + public TestDrawableHitObject(TestHitObject hitObject) + : base(hitObject) + { + Origin = Anchor.Centre; + Size = new Vector2(hitObject.Size); AddInternal(new Box { - Size = new Vector2(75), + RelativeSizeAxes = Axes.Both, Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) }); } } + + private class TestScrollingHitObjectContainer : ScrollingHitObjectContainer + { + protected override RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) + { + if (entry.HitObject is TestHitObject testObject) + return new RectangleF().Inflate(testObject.Size / 2); + + return base.GetConservativeBoundingBox(entry); + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs index 6b52e934b77e..60ed0012ae96 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs @@ -66,7 +66,8 @@ public void TestLocalUser() { AddSliderStep("score", 0, 1000000, 500000, v => scoreProcessor.TotalScore.Value = v); AddSliderStep("accuracy", 0f, 1f, 0.5f, v => scoreProcessor.Accuracy.Value = v); - AddSliderStep("combo", 0, 1000, 0, v => scoreProcessor.Combo.Value = v); + AddSliderStep("combo", 0, 10000, 0, v => scoreProcessor.HighestCombo.Value = v); + AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index e2c825df0bea..a26a7e97be41 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -32,6 +32,7 @@ public class TestSceneStoryboardWithOutro : PlayerTestScene protected new OutroPlayer Player => (OutroPlayer)base.Player; + private double currentBeatmapDuration; private double currentStoryboardDuration; private bool showResults = true; @@ -45,7 +46,8 @@ public override void 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 beatmap duration to 0s", () => currentBeatmapDuration = 0); + AddStep("set storyboard duration to 8s", () => currentStoryboardDuration = 8000); AddStep("set ShowResults = true", () => showResults = true); } @@ -151,6 +153,24 @@ public void TestPerformExitNoOutro() AddAssert("player exited", () => Stack.CurrentScreen == null); } + [Test] + public void TestPerformExitAfterOutro() + { + CreateTest(() => + { + AddStep("set beatmap duration to 4s", () => currentBeatmapDuration = 4000); + AddStep("set storyboard duration to 1s", () => currentStoryboardDuration = 1000); + }); + + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); + AddStep("exit via pause", () => Player.ExitViaPause()); + AddAssert("player paused", () => !Player.IsResuming); + + AddStep("resume player", () => Player.Resume()); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + protected override bool AllowFail => true; protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); @@ -160,7 +180,7 @@ public void TestPerformExitNoOutro() protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap(); - beatmap.HitObjects.Add(new HitCircle()); + beatmap.HitObjects.Add(new HitCircle { StartTime = currentBeatmapDuration }); return beatmap; } @@ -189,7 +209,7 @@ protected class OutroPlayer : TestPlayer private event Func failConditions; public OutroPlayer(Func failConditions, bool showResults = true) - : base(false, showResults) + : base(showResults: showResults) { this.failConditions = failConditions; } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index f31261dc1f1d..63677ce37874 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -97,14 +97,23 @@ public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost() } [Test] - public void TestCurrentItemDoesNotHaveDeleteButton() + public void TestSingleItemDoesNotHaveDeleteButton() + { + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + + assertDeleteButtonVisibility(0, false); + } + + [Test] + public void TestCurrentItemHasDeleteButtonIfNotSingle() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); addPlaylistItem(() => API.LocalUser.Value.OnlineID); - assertDeleteButtonVisibility(0, false); + assertDeleteButtonVisibility(0, true); assertDeleteButtonVisibility(1, true); AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index cf62c73ad4d7..6070b1456fa9 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -25,6 +25,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Skinning; +using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { @@ -79,6 +80,16 @@ public class TestSceneOsuGame : OsuGameTestScene [Resolved] private OsuGameBase gameBase { get; set; } + [Test] + public void TestCursorHidesWhenIdle() + { + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("wait until idle", () => Game.IsIdle.Value); + AddUntilStep("menu cursor hidden", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("menu cursor shown", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 1); + } + [Test] public void TestNullRulesetHandled() { diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 5e76fe151983..003cec0d07b1 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -9,8 +9,10 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; @@ -92,6 +94,31 @@ public void TestFromMainMenuDifferentRuleset([Values] ScorePresentType type) returnToMenu(); } + [Test] + public void TestFromSongSelectWithFilter([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + AddStep("filter to nothing", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).FilterControl.CurrentTextSearch.Value = "fdsajkl;fgewq"); + AddUntilStep("wait for no results", () => Beatmap.IsDefault); + + var firstImport = importScore(1, new CatchRuleset().RulesetInfo); + presentAndConfirm(firstImport, type); + } + + [Test] + public void TestFromSongSelectWithConvertRulesetChange([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + AddStep("set convert to false", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + var firstImport = importScore(1, new CatchRuleset().RulesetInfo); + presentAndConfirm(firstImport, type); + } + [Test] public void TestFromSongSelect([Values] ScorePresentType type) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs new file mode 100644 index 000000000000..bf01d3b0acfd --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs @@ -0,0 +1,257 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneCommentActions : OsuManualInputManagerTestScene + { + private Container content = null!; + protected override Container Content => content; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [Cached(typeof(IDialogOverlay))] + private readonly DialogOverlay dialogOverlay = new DialogOverlay(); + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private CommentsContainer commentsContainer = null!; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.AddRange(new Drawable[] + { + content = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both + }, + dialogOverlay + }); + } + + [SetUpSteps] + public void SetUp() + { + Schedule(() => + { + API.Login("test", "test"); + Child = commentsContainer = new CommentsContainer(); + }); + } + + [Test] + public void TestNonOwnCommentCantBeDeleted() + { + addTestComments(); + + AddUntilStep("First comment has button", () => + { + var comments = this.ChildrenOfType(); + var ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null && ourComment.ChildrenOfType().Any(x => x.Text == "Delete"); + }); + + AddAssert("Second doesn't", () => + { + var comments = this.ChildrenOfType(); + var ourComment = comments.Single(x => x.Comment.Id == 2); + return ourComment.ChildrenOfType().All(x => x.Text != "Delete"); + }); + } + + private readonly ManualResetEventSlim deletionPerformed = new ManualResetEventSlim(); + + [Test] + public void TestDeletion() + { + DrawableComment? ourComment = null; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null; + }); + AddStep("It has delete button", () => + { + var btn = ourComment.ChildrenOfType().Single(x => x.Text == "Delete"); + InputManager.MoveMouseTo(btn); + }); + AddStep("Click delete button", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("Setup request handling", () => + { + deletionPerformed.Reset(); + + dummyAPI.HandleRequest = request => + { + if (!(request is CommentDeleteRequest req)) + return false; + + if (req.CommentId != 1) + return false; + + CommentBundle cb = new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 2, + Message = "This is a comment by another user", + UserId = API.LocalUser.Value.Id + 1, + CreatedAt = DateTimeOffset.Now, + User = new APIUser + { + Id = API.LocalUser.Value.Id + 1, + Username = "Another user" + } + }, + }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + + Task.Run(() => + { + deletionPerformed.Wait(10000); + req.TriggerSuccess(cb); + }); + + return true; + }; + }); + AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); + + AddAssert("Loading spinner shown", () => commentsContainer.ChildrenOfType().Any(d => d.IsPresent)); + + AddStep("Complete request", () => deletionPerformed.Set()); + + AddUntilStep("Comment is deleted locally", () => this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted); + } + + [Test] + public void TestDeletionFail() + { + DrawableComment? ourComment = null; + bool delete = false; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null; + }); + AddStep("It has delete button", () => + { + var btn = ourComment.ChildrenOfType().Single(x => x.Text == "Delete"); + InputManager.MoveMouseTo(btn); + }); + AddStep("Click delete button", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("Setup request handling", () => + { + dummyAPI.HandleRequest = request => + { + if (request is not CommentDeleteRequest req) + return false; + + req.TriggerFailure(new Exception()); + delete = true; + return false; + }; + }); + AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); + AddUntilStep("Deletion requested", () => delete); + AddUntilStep("Comment is available", () => + { + return !this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted; + }); + AddAssert("Loading spinner hidden", () => + { + return ourComment.ChildrenOfType().All(d => !d.IsPresent); + }); + AddAssert("Actions available", () => + { + return ourComment.ChildrenOfType().Single(x => x.Name == @"Actions buttons").IsPresent; + }); + } + + private void addTestComments() + { + AddStep("set up response", () => + { + CommentBundle cb = new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "This is our comment", + UserId = API.LocalUser.Value.Id, + CreatedAt = DateTimeOffset.Now, + User = API.LocalUser.Value, + }, + new Comment + { + Id = 2, + Message = "This is a comment by another user", + UserId = API.LocalUser.Value.Id + 1, + CreatedAt = DateTimeOffset.Now, + User = new APIUser + { + Id = API.LocalUser.Value.Id + 1, + Username = "Another user" + } + }, + }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + setUpCommentsResponse(cb); + }); + + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + } + + private void setUpCommentsResponse(CommentBundle commentBundle) + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetCommentsRequest getCommentsRequest)) + return false; + + getCommentsRequest.TriggerSuccess(commentBundle); + return true; + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index 39432ee05967..863b352618ea 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -189,6 +189,16 @@ public void TestWideImageNotExceedContainer() }); } + [Test] + public void TestFlag() + { + AddStep("Add flag", () => + { + markdownContainer.CurrentPath = @"https://dev.ppy.sh"; + markdownContainer.Text = "::{flag=\"AU\"}:: ::{flag=\"ZZ\"}::"; + }); + } + private class TestMarkdownContainer : WikiMarkdownContainer { public LinkInline Link; diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 558bff2f3c9c..27cd74bb1f98 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -100,7 +100,7 @@ private void setUpWikiResponse(APIWikiPage r, string redirectionPath = null) Locale = "en", Subtitle = "Article styling criteria", Markdown = - "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\n\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119 \"IETF Tools\") to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | اَلْعَرَبِيَّةُ |\n| `be.md` | Belarusian | Беларуская мова |\n| `bg.md` | Bulgarian | Български |\n| `cs.md` | Czech | Česky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `gr.md` | Greek | Ελληνικά |\n| `es.md` | Spanish | Español |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Français |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | 日本語 |\n| `ko.md` | Korean | 한국어 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Português |\n| `pt-br.md` | Brazilian Portuguese | Português (Brasil) |\n| `ro.md` | Romanian | Română |\n| `ru.md` | Russian | Русский |\n| `sk.md` | Slovak | Slovenčina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | ไทย |\n| `tr.md` | Turkish | Türkçe |\n| `uk.md` | Ukrainian | Українська мова |\n| `vi.md` | Vietnamese | Tiếng Việt |\n| `zh.md` | Chinese (Simplified) | 简体中文 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | 繁體中文(台灣) |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n### Content parity\n\nTranslations are subject to strict content parity with their English article, in the sense that they must have the same message, regardless of grammar and syntax. Any changes to the translations' meanings must be accompanied by equivalent changes to the English article.\n\nThere are some cases where the content is allowed to differ:\n\n- Articles originally written in a language other than English (in this case, English should act as the translation)\n- Explanations of English words that are common terms in the osu! community\n- External links\n- Tags\n- Subcommunity-specific explanations\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example \"YAML Wikipedia article\") and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nTranslated articles that are outdated must use the `outdated` tag when the English variant is updated. English articles may also become outdated when the content they contain is misleading or no longer relevant. This tag must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals) \"Wikipedia\") for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}`*\n\n*For {description}, see: {article} and {article}`*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnotes), must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Emphasising\n\n### Bold\n\nBold must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nNames of work or video games should be italicised. osu!—the game—is exempt from this.\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings \"GitHub\") and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\n\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents. They cannot be linked to directly either.*\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game Modifiers](/wiki/Game_Modifiers)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game Modifiers][game mods link]\n\n[game mods link]: /wiki/Game_Modifiers\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#-pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/Developers)\n[Developers](/wiki/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Beatmap_Editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`§`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria § Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficuty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The link text should be the title of the page it is linking to. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory \"Wikipedia\")*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![](/wiki/shared/flag/AU.gif)\n```\n\n**Reference style:**\n\n```markdown\n![][flag_AU]\n\n[flag_AU]: /wiki/shared/flag/AU.gif\n```\n\nImages should use the inline linking style. References to reference links must be placed at the bottom of the article.\n\nImages must be placed in a folder named `img`, located in the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared/` folder.\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive \"GitHub\") to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\n### Formatting and positioning\n\n*Note: It is currently not possible to float an image or have text wrap around it.*\n\nImages on the website will be centred when it is on a single line, by themself. Otherwise, they will be positioned inline with the paragraph. The following example will place the image in the center:\n\n```markdown\nInstalling osu! is easy. First, download the installer from the download page.\n\n![](img/download-page.jpg)\n\nThen locate the installer and run it.\n```\n\n### Alt text\n\nImages should have alt text unless it is for decorative purposes.\n\n### Captions\n\nImages are given captions on the website if they fulfill these conditions:\n\n1. The image is by itself.\n2. The image is not inside a heading.\n3. The image has title text.\n\nCaptions are assumed via the title text, which must be in plain text. Images with captions are also centred with the image on the website.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\nFlag icons next to user links must be separate from the link text. See the following example:\n\n```markdown\n![][flag_AU] [peppy](https://osu.ppy.sh/users/2)\n```\n\n### Flag icons\n\n*For a list of flag icons, see: [issue \\#328](https://github.com/ppy/osu-wiki/issues/328 \"GitHub\")*\n\nThe flag icons use the two letter code (in all capital letters) and end with `.gif`. When adding a flag inline, use this format:\n\n```markdown\n![](/wiki/shared/flag/xx.gif)\n```\n\nWhere `xx` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 \"Wikipedia\") two-lettered country code for the flag.\n\nThe full country name should be added in the title text. The country code in the alternate text is optional, but must be applied to all flag icons in the article.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ryūta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Blockquotes\n\nThe blockquote is limited to quoting text from someone. It must not be used to format text otherwise.\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" + "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\\\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119) to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | \u0627\u064e\u0644\u0652\u0639\u064e\u0631\u064e\u0628\u0650\u064a\u064e\u0651\u0629\u064f |\n| `be.md` | Belarusian | \u0411\u0435\u043b\u0430\u0440\u0443\u0441\u043a\u0430\u044f \u043c\u043e\u0432\u0430 |\n| `bg.md` | Bulgarian | \u0411\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 |\n| `cs.md` | Czech | \u010cesky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `el.md` | Greek | \u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac |\n| `es.md` | Spanish | Espa\u00f1ol |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Fran\u00e7ais |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | \u65e5\u672c\u8a9e |\n| `ko.md` | Korean | \ud55c\uad6d\uc5b4 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Portugu\u00eas |\n| `pt-br.md` | Brazilian Portuguese | Portugu\u00eas (Brasil) |\n| `ro.md` | Romanian | Rom\u00e2n\u0103 |\n| `ru.md` | Russian | \u0420\u0443\u0441\u0441\u043a\u0438\u0439 |\n| `sk.md` | Slovak | Sloven\u010dina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | \u0e44\u0e17\u0e22 |\n| `tr.md` | Turkish | T\u00fcrk\u00e7e |\n| `uk.md` | Ukrainian | \u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430 \u043c\u043e\u0432\u0430 |\n| `vi.md` | Vietnamese | Ti\u1ebfng Vi\u1ec7t |\n| `zh.md` | Chinese (Simplified) | \u7b80\u4f53\u4e2d\u6587 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | \u7e41\u9ad4\u4e2d\u6587\uff08\u53f0\u7063\uff09 |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example) and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nEnglish articles may become outdated when the content they contain is misleading or no longer relevant. These should receive an `outdated` tag, which must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Outdated translations\n\nTranslated articles that are outdated must use the `outdated_translation` tag when the English variant is updated, except for minor wording, grammar changes, and the like, that do not affect the meaning of the article.\n\n```yaml\noutdated_translation: true\n```\n\nWhen outdating translations, they must also receive an `outdated_since` tag that points to the first commit where the English version is updated.\n\n```yaml\noutdated_since: 29eac89cd535f8b071ca000af8fe4f0be22bdc9b\n```\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals)) for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}*\n\n*For {description}, see: {article} and {article}*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnote), notices must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Stacked hatnotes and notices\n\nMultiple hatnotes and notices may be stacked when necessary. When doing this, they must be stacked without blank lines and use trailing backslashes:\n\n```markdown\n*Warning: {warning}.*\\\n*See also: {article}*\n```\n\nIn many cases, it may be more fitting to embed extraneous hatnotes or notices into paragraph text instead of stacking many of them.\n\n## Emphasising\n\n### Bolding\n\nBold text must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\nNames of work or video games should be italicised. osu! \u2014 the game \u2014 is exempt from this.\n\nAs an example, when referring to songs in the format of `{artist} - {title}`, the whole part is a reference to the work and should therefore be italicised:\n\n```markdown\n*cYsmix - triangles* is a one of the three intro songs that can be heard when starting the game client.\n```\n\nArtist names are otherwise generally not italicised. This means that in free-form references, only the title should be italicised, because the artist name is then not part of the name of the work:\n\n```markdown\n*Blue Zenith* by xi is an infamous song in the osu! community due to a famous score set by a top player on a certain beatmap.\n```\n\n### Emphasis and links\n\nLinked text appears in a different colour which already provides emphasis, and therefore does not need further emphasis:\n\n```markdown\n[Camellia - OOPARTS](https://cametek.bandcamp.com/track/parts) is an example of a song officially created specifically for osu!, otherwise known as an *osu! original*, since it was specifically commissioned for the osu! World Cup 2020 tiebreaker.\n```\n\nThis however does not apply if the referenced work is not the only part of the link:\n\n```markdown\n[Voltaeyx's beatmap of *TheFatRat - Mayday (feat. Laura Brehm)*](https://osu.ppy.sh/beatmapsets/756794) amassed considerable popularity in 2018 due to its unique overlapping slider patterns.\n```\n\nThe above type of construction should be used sparingly, and must not be used in places with many links, such as tables or lists.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings) and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\\\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents.*\n\n### Custom identifiers\n\nIt is possible to redefine a section's identifier, which is used for linking to it directly. Custom identifiers should be used in case the automatically generated ones are too long or contain tricky punctuation marks or images:\n\n\n\n```markdown\n## My cooldown has passed. How do I appeal? {#appeal}\n\n## Common restriction reasons and cooldowns {#common-reasons}\n\n## Ideas for a multiplayer match {id=\u0438\u0434\u0435\u0438-\u0434\u043b\u044f-\u043c\u0443\u043b\u044c\u0442\u0438\u043f\u043b\u0435\u0435\u0440\u0430} \n```\n\nThis feature can also be used for tagging a specific part of the article which doesn't have a heading. Use it sparingly:\n\n```markdown\n> That's it! You're well on your way to becoming an osu! rhythm champion!\n{#tutorial-quote}\n```\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\n*See also: [Footnotes](#footnotes)*\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game modifier](/wiki/Game_modifier)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game modifier][game mods link]\n\n[game mods link]: /wiki/Game_modifier\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/People/The_Team/Developers)\n[Developers](/wiki/People/The_Team/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Client/Beatmap_editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`\u00a7`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria \u00a7 Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficulty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory)*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![Gold crown](/wiki/shared/crown-gold.png \"1st place\")\n```\n\n**Reference style:**\n\n```markdown\n![Gold crown][GCrown]\n\n[GCrown]: /wiki/shared/crown-gold.png \"1st place\"\n```\n\nImages should use the inline linking style. Reference link definitions must be placed at the bottom of the article.\n\nAll block images on the page (that have nothing else on the same line) are combined into a single gallery, which can be navigated using arrow icons on both sides of the screen, keyboard shortcuts, or screen swipes on mobile devices.\n\n### Alternative and title text\n\nThe text in the first pair of square brackets (*alternative text*) should describe the image literally. It is used by screen readers or when the image fails to load. It can be omitted if it is identical to the title text or if the image is included only for decorative purposes.\n\nThe text in the quotation marks (*title text*) should give additional context to the image or indicate its meaning. It is displayed as a tooltip when hovering over the image and used as a caption if applicable. It does not support any markdown formatting.\n\n### Display\n\nIf an image is the sole content of a paragraph, it displays as a centred block. Otherwise, it flows with the surrounding inline text.\n\nBlock images with title text display the title text as a caption below the image. Avoid adding [HTML comment](#comments) or any other text on the same line as the image, as this will cause the caption not to be rendered.\n\nBlock images are commonly paired with [infobox](#infoboxes) formatting to reduce their initial size and float them to the side of other content:\n\n```markdown\n::: Infobox\n![](img/mod-response.png \"An example of a response to a mod\")\n:::\n```\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive) to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\nImages must be placed in a folder named `img` under the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared` folder.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\n## Flag icons\n\nThe flag icons use the two letter code (in all capital letters) to match a certain territory. When adding a flag inline, use this format:\n\n```markdown\n::{ flag=XX }::\n```\n\nWhere `XX` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) two-lettered country code for the flag.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ry\u016bta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Infoboxes\n\nAn infobox is a fixed-width block which is aligned to the right side of the article. It may contain a relevant image, which explains the surrounding text, or a block of navigation that links to other articles from the same category.\n\nExample use, rendered on the right:\n\n\n\n::: Infobox\n![](/wiki/shared/mods/SD.png \"Sudden Death mod icon\")\n:::\n\n```markdown\n::: Infobox\n![](/wiki/shared/mods/SD.png \"Sudden Death mod icon\")\n:::\n```\n\nInfoboxes should be used with caution in the following cases:\n\n- Short sections: the next section's heading appears below any infoboxes, leaving a large gap after the text.\n- Several images at once: instead, use individual infoboxes for better design.\n\n## Footnotes\n\nFootnotes are short notes located at the end of the page. They are used for citing sources, or providing background information that would otherwise disrupt the flow of the article. Footnotes may contain text formatting and links.\n\nIn the osu! wiki, footnotes are implemented using special syntax (`[^identifier]`). Footnotes can use any identifier, but they will automatically be rendered as superscripts with increasing numbers in order of their first appearance. Translations must not modify identifiers of footnotes.\n\nFootnote references are placed directly after the words, phrases, or sentences they explain, with no space in between. These references must be placed after punctuation, except for parentheses, when they pertain to the contents inside, and dashes.\n\nThe footnotes themselves must be placed in a separate second-level heading at the end of the article. Depending on the content, the heading used may be `References`, `Notes`, or `Notes and references`.\n\nCorrect usage examples:\n\n```markdown\nThe osu! wiki is a project that was meant to replace the old FAQ system.[^wiki-faq] It was named after the rhythm game osu![^osu] and the largest open online encyclopedia, Wikipedia. From the very start, it had attracted skillful translators[^wiki-tl] and editors.\n\n## References\n\n[^wiki-faq]: https://osu.ppy.sh/community/forums/topics/68525\n[^wiki-tl]: https://osu.ppy.sh/community/forums/posts/1177500\n[^osu]: https://osu.ppy.sh/community/forums/posts/1178153\n```\n\n### Citations\n\nCitations, or references, are used to identify a source of information. Citations via footnotes should be preferred over inline links.\n\nReferences should whenever applicable specify author, date, service/platform, and title. The exact format may vary depending on the referenced material with a preference for brevity.\n\nExamples:\n\n```markdown\nThe first version of the osu!api was made available on July 2, 2013.[^api-first-usage] It had received critical acclaim from users.[^api-praise] A new version of API, released several years later, contains many more capabilities.[^api-v2-2020] Endpoint versioning is common among web APIs.[^web-api]\n\n## References\n\n[^api-first-usage]: [Forum thread by peppy (2013-07-02) \"osu!api open beta\"](https://osu.ppy.sh/community/forums/posts/2403913)\n[^api-praise]: [Forum post by Menchi (2013-11-02) in \"osu!api open beta\"](https://osu.ppy.sh/community/forums/posts/2662247)\n[^api-v2-2020]: [Tweet by @ppy (2020-03-20)](https://twitter.com/ppy/status/1263083636363948032)\n[^web-api]: [\"Web API\" on Wikipedia](https://en.wikipedia.org/wiki/Web_API)\n```\n\n### Notes\n\nFootnotes may be used for storing explanations or tangential remarks which cannot be inlined without worsening the article's readability, or are less significant than the article itself. Such footnotes may use free-form text.\n\nExample:\n\n```markdown\nA tournament must not be organised and run by an inexperienced team of unaccomplished and irreputable staff.[^staff]\n\n## Notes\n\n[^staff]: An *inexperienced* staff member is loosely defined as someone who has been playing osu! for less than an hour in total.\n```\n\n## Blockquotes\n\nThe blockquote is limited to [quoting someone or something](/wiki/Article_styling_criteria/Writing#block-quotation). It must not be used to format text otherwise.\n\n```markdown\n> plz enjoy game\n\n\u2014rrtyui\n```\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" }; // From https://osu.ppy.sh/api/v2/wiki/en/Article_styling_criteria diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs index 4f05194e0855..16110e559520 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs @@ -3,18 +3,17 @@ #nullable disable -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Tests.Visual.UserInterface; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneDirectorySelector : OsuTestScene + public class TestSceneDirectorySelector : ThemeComparisonTestScene { - [BackgroundDependencyLoader] - private void load() + protected override Drawable CreateContent() => new OsuDirectorySelector { - Add(new OsuDirectorySelector { RelativeSizeAxes = Axes.Both }); - } + RelativeSizeAxes = Axes.Both + }; } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs index 6f25012bfabf..97bf0d212aab 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs @@ -4,23 +4,43 @@ #nullable disable using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Tests.Visual.UserInterface; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneFileSelector : OsuTestScene + public class TestSceneFileSelector : ThemeComparisonTestScene { - [Test] - public void TestAllFiles() - { - AddStep("create", () => Child = new OsuFileSelector { RelativeSizeAxes = Axes.Both }); - } + [Resolved] + private OsuColour colours { get; set; } [Test] public void TestJpgFilesOnly() { - AddStep("create", () => Child = new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) { RelativeSizeAxes = Axes.Both }); + AddStep("create", () => + { + Cell(0, 0).Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeaFoam + }, + new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) + { + RelativeSizeAxes = Axes.Both, + }, + }; + }); } + + protected override Drawable CreateContent() => new OsuFileSelector + { + RelativeSizeAxes = Axes.Both, + }; } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 0e72463d1e06..eacaf7f92e1e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -862,52 +862,6 @@ public void TestCarouselRemembersSelection() AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); } - [Test] - public void TestRandomFallbackOnNonMatchingPrevious() - { - List manySets = new List(); - - AddStep("populate maps", () => - { - manySets.Clear(); - - for (int i = 0; i < 10; i++) - { - manySets.Add(TestResources.CreateTestBeatmapSetInfo(3, new[] - { - // all taiko except for first - rulesets.GetRuleset(i > 0 ? 1 : 0) - })); - } - }); - - loadBeatmaps(manySets); - - for (int i = 0; i < 10; i++) - { - AddStep("Reset filter", () => carousel.Filter(new FilterCriteria(), false)); - - AddStep("select first beatmap", () => carousel.SelectBeatmap(manySets.First().Beatmaps.First())); - - AddStep("Toggle non-matching filter", () => - { - carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); - }); - - AddAssert("selection lost", () => carousel.SelectedBeatmapInfo == null); - - AddStep("Restore different ruleset filter", () => - { - carousel.Filter(new FilterCriteria { Ruleset = rulesets.GetRuleset(1) }, false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); - }); - - AddAssert("selection changed", () => !carousel.SelectedBeatmapInfo!.Equals(manySets.First().Beatmaps.First())); - } - - AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); - } - [Test] public void TestFilteringByUserStarDifficulty() { @@ -955,6 +909,63 @@ public void TestFilteringByUserStarDifficulty() checkVisibleItemCount(true, 15); } + [Test] + public void TestCarouselSelectsNextWhenPreviousIsFiltered() + { + List sets = new List(); + + // 10 sets that go osu! -> taiko -> catch -> osu! -> ... + for (int i = 0; i < 10; i++) + { + var rulesetInfo = rulesets.AvailableRulesets.ElementAt(i % 3); + sets.Add(TestResources.CreateTestBeatmapSetInfo(5, new[] { rulesetInfo })); + } + + // Sort mode is important to keep the ruleset order + loadBeatmaps(sets, () => new FilterCriteria { Sort = SortMode.Title }); + setSelected(1, 1); + + for (int i = 1; i < 10; i++) + { + var rulesetInfo = rulesets.AvailableRulesets.ElementAt(i % 3); + AddStep($"Set ruleset to {rulesetInfo.ShortName}", () => + { + carousel.Filter(new FilterCriteria { Ruleset = rulesetInfo, Sort = SortMode.Title }, false); + }); + waitForSelection(i + 1, 1); + } + } + + [Test] + public void TestCarouselSelectsBackwardsWhenDistanceIsShorter() + { + List sets = new List(); + + // 10 sets that go taiko, osu!, osu!, osu!, taiko, osu!, osu!, osu!, ... + for (int i = 0; i < 10; i++) + { + var rulesetInfo = rulesets.AvailableRulesets.ElementAt(i % 4 == 0 ? 1 : 0); + sets.Add(TestResources.CreateTestBeatmapSetInfo(5, new[] { rulesetInfo })); + } + + // Sort mode is important to keep the ruleset order + loadBeatmaps(sets, () => new FilterCriteria { Sort = SortMode.Title }); + + for (int i = 2; i < 10; i += 4) + { + setSelected(i, 1); + AddStep("Set ruleset to taiko", () => + { + carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1), Sort = SortMode.Title }, false); + }); + waitForSelection(i - 1, 1); + AddStep("Remove ruleset filter", () => + { + carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false); + }); + } + } + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, bool randomDifficulties = false) { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs index 70786c93e7e9..96cfbe4dd170 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs @@ -6,14 +6,18 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Tests.Online; using osu.Game.Tests.Resources; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { @@ -41,17 +45,7 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl [SetUpSteps] public void SetUpSteps() { - AddStep("create carousel", () => - { - Child = carousel = new BeatmapCarousel - { - RelativeSizeAxes = Axes.Both, - BeatmapSets = new List - { - (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo()), - } - }; - }); + AddStep("create carousel", () => Child = createCarousel()); AddUntilStep("wait for load", () => carousel.BeatmapSetsLoaded); @@ -152,5 +146,62 @@ public void TestDownloadFailed() AddUntilStep("wait for button enabled", () => getUpdateButton()?.Enabled.Value == true); } + + [Test] + public void TestUpdateLocalBeatmap() + { + DialogOverlay dialogOverlay = null!; + UpdateBeatmapSetButton? updateButton = null; + + AddStep("create carousel with dialog overlay", () => + { + dialogOverlay = new DialogOverlay(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(IDialogOverlay), dialogOverlay), }, + Children = new Drawable[] + { + createCarousel(), + dialogOverlay, + }, + }; + }); + + AddStep("setup beatmap state", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + testBeatmapSetInfo.Status = BeatmapOnlineStatus.LocallyModified; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("wait for update button", () => (updateButton = getUpdateButton()) != null); + AddStep("click button", () => updateButton.AsNonNull().TriggerClick()); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is UpdateLocalConfirmationDialog); + AddStep("click confirmation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); + InputManager.PressButton(MouseButton.Left); + }); + + AddUntilStep("update started", () => beatmapDownloader.GetExistingDownload(testBeatmapSetInfo) != null); + AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + } + + private BeatmapCarousel createCarousel() + { + return carousel = new BeatmapCarousel + { + RelativeSizeAxes = Axes.Both, + BeatmapSets = new List + { + (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo()), + } + }; + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs index bcbe1464563b..75c47f0b1b9a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { @@ -81,25 +82,24 @@ public TestSceneCursors() }; AddToggleStep("Smooth transitions", b => cursorBoxes.ForEach(box => box.SmoothTransition = b)); - - testUserCursor(); - testLocalCursor(); - testUserCursorOverride(); - testMultipleLocalCursors(); } + [SetUp] + public void SetUp() => Schedule(moveOut); + /// /// -- Green Box -- /// Tests whether hovering in and out of a drawable that provides the user cursor (green) /// results in the correct visibility state for that cursor. /// - private void testUserCursor() + [Test] + public void TestUserCursor() { AddStep("Move to green area", () => InputManager.MoveMouseTo(cursorBoxes[0])); - AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].MenuCursor)); - AddAssert("Check green cursor at mouse", () => checkAtMouse(cursorBoxes[0].MenuCursor)); + AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check green cursor at mouse", () => checkAtMouse(cursorBoxes[0].Cursor)); AddStep("Move out", moveOut); - AddAssert("Check green cursor invisible", () => !checkVisible(cursorBoxes[0].MenuCursor)); + AddAssert("Check green cursor invisible", () => !checkVisible(cursorBoxes[0].Cursor)); AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); } @@ -108,15 +108,16 @@ private void testUserCursor() /// Tests whether hovering in and out of a drawable that provides a local cursor (purple) /// results in the correct visibility and state for that cursor. /// - private void testLocalCursor() + [Test] + public void TestLocalCursor() { AddStep("Move to purple area", () => InputManager.MoveMouseTo(cursorBoxes[3])); - AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].MenuCursor)); - AddAssert("Check purple cursor at mouse", () => checkAtMouse(cursorBoxes[3].MenuCursor)); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddAssert("Check purple cursor at mouse", () => checkAtMouse(cursorBoxes[3].Cursor)); AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); AddAssert("Check global cursor at mouse", () => checkAtMouse(globalCursorDisplay.MenuCursor)); AddStep("Move out", moveOut); - AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].MenuCursor)); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); } @@ -125,47 +126,98 @@ private void testLocalCursor() /// Tests whether overriding a user cursor (green) with another user cursor (blue) /// results in the correct visibility and states for the cursors. /// - private void testUserCursorOverride() + [Test] + public void TestUserCursorOverride() { AddStep("Move to blue-green boundary", () => InputManager.MoveMouseTo(cursorBoxes[1].ScreenSpaceDrawQuad.BottomRight - new Vector2(10))); - AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].MenuCursor)); - AddAssert("Check green cursor invisible", () => !checkVisible(cursorBoxes[0].MenuCursor)); - AddAssert("Check blue cursor at mouse", () => checkAtMouse(cursorBoxes[1].MenuCursor)); + AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].Cursor)); + AddAssert("Check green cursor invisible", () => !checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check blue cursor at mouse", () => checkAtMouse(cursorBoxes[1].Cursor)); AddStep("Move out", moveOut); - AddAssert("Check blue cursor not visible", () => !checkVisible(cursorBoxes[1].MenuCursor)); - AddAssert("Check green cursor not visible", () => !checkVisible(cursorBoxes[0].MenuCursor)); + AddAssert("Check blue cursor not visible", () => !checkVisible(cursorBoxes[1].Cursor)); + AddAssert("Check green cursor not visible", () => !checkVisible(cursorBoxes[0].Cursor)); } /// /// -- Yellow-Purple Box Boundary -- /// Tests whether multiple local cursors (purple + yellow) may be visible and at the mouse position at the same time. /// - private void testMultipleLocalCursors() + [Test] + public void TestMultipleLocalCursors() { AddStep("Move to yellow-purple boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.BottomRight - new Vector2(10))); - AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].MenuCursor)); - AddAssert("Check purple cursor at mouse", () => checkAtMouse(cursorBoxes[3].MenuCursor)); - AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].MenuCursor)); - AddAssert("Check yellow cursor at mouse", () => checkAtMouse(cursorBoxes[5].MenuCursor)); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddAssert("Check purple cursor at mouse", () => checkAtMouse(cursorBoxes[3].Cursor)); + AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); + AddAssert("Check yellow cursor at mouse", () => checkAtMouse(cursorBoxes[5].Cursor)); AddStep("Move out", moveOut); - AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].MenuCursor)); - AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].MenuCursor)); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); } /// /// -- Yellow-Blue Box Boundary -- /// Tests whether a local cursor (yellow) may be displayed along with a user cursor override (blue). /// - private void testUserOverrideWithLocal() + [Test] + public void TestUserOverrideWithLocal() { - AddStep("Move to yellow-blue boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.TopRight - new Vector2(10))); - AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].MenuCursor)); - AddAssert("Check blue cursor at mouse", () => checkAtMouse(cursorBoxes[1].MenuCursor)); - AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].MenuCursor)); - AddAssert("Check yellow cursor at mouse", () => checkAtMouse(cursorBoxes[5].MenuCursor)); + AddStep("Move to yellow-blue boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.TopRight - new Vector2(10, 0))); + AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].Cursor)); + AddAssert("Check blue cursor at mouse", () => checkAtMouse(cursorBoxes[1].Cursor)); + AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); + AddAssert("Check yellow cursor at mouse", () => checkAtMouse(cursorBoxes[5].Cursor)); AddStep("Move out", moveOut); - AddAssert("Check blue cursor invisible", () => !checkVisible(cursorBoxes[1].MenuCursor)); - AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].MenuCursor)); + AddAssert("Check blue cursor invisible", () => !checkVisible(cursorBoxes[1].Cursor)); + AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); + } + + /// + /// Ensures non-mouse input hides global cursor on a "local cursor" area (which doesn't hide global cursor). + /// + [Test] + public void TestKeyboardLocalCursor([Values] bool clickToShow) + { + AddStep("Enable cursor hiding", () => globalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = true); + AddStep("Move to purple area", () => InputManager.MoveMouseTo(cursorBoxes[3].ScreenSpaceDrawQuad.Centre + new Vector2(10, 0))); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddAssert("Check global cursor alpha is 1", () => globalCursorDisplay.MenuCursor.Alpha == 1); + + AddStep("Press key", () => InputManager.Key(Key.A)); + AddAssert("Check purple cursor still visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddUntilStep("Check global cursor alpha is 0", () => globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + if (clickToShow) + AddStep("Click mouse", () => InputManager.Click(MouseButton.Left)); + else + AddStep("Move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + Vector2.One)); + + AddAssert("Check purple cursor still visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddUntilStep("Check global cursor alpha is 1", () => globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 1); + } + + /// + /// Ensures mouse input after non-mouse input doesn't show global cursor on a "user cursor" area (which hides global cursor). + /// + [Test] + public void TestKeyboardUserCursor([Values] bool clickToShow) + { + AddStep("Enable cursor hiding", () => globalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = true); + AddStep("Move to green area", () => InputManager.MoveMouseTo(cursorBoxes[0])); + AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + AddStep("Press key", () => InputManager.Key(Key.A)); + AddAssert("Check green cursor still visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is still 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + if (clickToShow) + AddStep("Click mouse", () => InputManager.Click(MouseButton.Left)); + else + AddStep("Move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + Vector2.One)); + + AddAssert("Check green cursor still visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is still 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); } /// @@ -191,7 +243,7 @@ private class CustomCursorBox : Container, IProvideCursor { public bool SmoothTransition; - public CursorContainer MenuCursor { get; } + public CursorContainer Cursor { get; } public bool ProvidingUserCursor { get; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || (SmoothTransition && !ProvidingUserCursor); @@ -218,7 +270,7 @@ public CustomCursorBox(Color4 cursorColour, bool providesUserCursor = true) Origin = Anchor.Centre, Text = providesUserCursor ? "User cursor" : "Local cursor" }, - MenuCursor = new TestCursorContainer + Cursor = new TestCursorContainer { State = { Value = providesUserCursor ? Visibility.Hidden : Visibility.Visible }, } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs index 50e506f82bbb..9fb0905a4fd1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs @@ -5,12 +5,14 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Overlays.Settings; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -20,11 +22,17 @@ public class TestSceneSettingsToolboxGroup : OsuManualInputManagerTestScene { private SettingsToolboxGroup group; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void SetUp() => Schedule(() => { Child = group = new SettingsToolboxGroup("example") { + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Children = new Drawable[] { new RoundedButton diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 2572864e093a..bdf8cc513601 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,11 +1,11 @@  - - + + - + diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 5512b268630f..bdef46a6b281 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,9 +4,9 @@ osu.Game.Tournament.Tests.TournamentTestRunner - + - + WinExe diff --git a/osu.Game.Tournament/JsonPointConverter.cs b/osu.Game.Tournament/JsonPointConverter.cs index db48c36c99ed..d3b40a352687 100644 --- a/osu.Game.Tournament/JsonPointConverter.cs +++ b/osu.Game.Tournament/JsonPointConverter.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics; using System.Drawing; +using System.Globalization; using Newtonsoft.Json; namespace osu.Game.Tournament @@ -31,7 +32,9 @@ public override Point ReadJson(JsonReader reader, Type objectType, Point existin Debug.Assert(str != null); - return new PointConverter().ConvertFromString(str) as Point? ?? new Point(); + // Null check suppression is required due to .NET standard expecting a non-null context. + // Seems to work fine at a runtime level (and the parameter is nullable in .NET 6+). + return new PointConverter().ConvertFromString(null!, CultureInfo.InvariantCulture, str) as Point? ?? new Point(); } var point = new Point(); diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 1bc929604d20..348661e2a326 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -239,17 +239,17 @@ private void load() var req = new GetBeatmapRequest(new APIBeatmap { OnlineID = Model.ID }); - req.Success += res => + req.Success += res => Schedule(() => { Model.Beatmap = new TournamentBeatmap(res); updatePanel(); - }; + }); - req.Failure += _ => + req.Failure += _ => Schedule(() => { Model.Beatmap = null; updatePanel(); - }; + }); API.Queue(req); }, true); diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 292caa4397e1..bcb1d7f961ba 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -141,18 +141,9 @@ private static void transferCollectionReferences(Realm realm, BeatmapSetInfo ori // Handle collections using permissive difficulty name to track difficulties. foreach (var originalBeatmap in original.Beatmaps) { - var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.DifficultyName == originalBeatmap.DifficultyName); - - if (updatedBeatmap == null) - continue; - - var collections = realm.All().AsEnumerable().Where(c => c.BeatmapMD5Hashes.Contains(originalBeatmap.MD5Hash)); - - foreach (var c in collections) - { - c.BeatmapMD5Hashes.Remove(originalBeatmap.MD5Hash); - c.BeatmapMD5Hashes.Add(updatedBeatmap.MD5Hash); - } + updated.Beatmaps + .FirstOrDefault(b => b.DifficultyName == originalBeatmap.DifficultyName)? + .TransferCollectionReferences(realm, originalBeatmap.MD5Hash); } } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 32b7f0b29b10..6f9df1ba7f0c 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using osu.Framework.Testing; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; @@ -213,6 +214,23 @@ private static bool compareFiles(BeatmapInfo x, BeatmapInfo y, Func + /// When updating a beatmap, its hashes will change. Collections currently track beatmaps by hash, so they need to be updated. + /// This method will handle updating + /// + /// A realm instance in an active write transaction. + /// The previous MD5 hash of the beatmap before update. + public void TransferCollectionReferences(Realm realm, string previousMD5Hash) + { + var collections = realm.All().AsEnumerable().Where(c => c.BeatmapMD5Hashes.Contains(previousMD5Hash)); + + foreach (var c in collections) + { + c.BeatmapMD5Hashes.Remove(previousMD5Hash); + c.BeatmapMD5Hashes.Add(MD5Hash); + } + } + IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 2c6edb64f81d..befc56d2449e 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -311,6 +311,8 @@ public virtual void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin if (existingFileInfo != null) DeleteFile(setInfo, existingFileInfo); + string oldMd5Hash = beatmapInfo.MD5Hash; + beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); beatmapInfo.Hash = stream.ComputeSHA2Hash(); @@ -327,6 +329,8 @@ public virtual void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin setInfo.CopyChangesToRealm(liveBeatmapSet); + beatmapInfo.TransferCollectionReferences(r, oldMd5Hash); + ProcessBeatmap?.Invoke((liveBeatmapSet, false)); }); } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index 56a432aec43a..0a09e6e7e6c8 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -9,11 +9,8 @@ namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable, IDeepCloneable, IEquatable + public abstract class ControlPoint : IComparable, IDeepCloneable, IEquatable, IControlPoint { - /// - /// The time at which the control point takes effect. - /// [JsonIgnore] public double Time { get; set; } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 4be6b5eede0e..422e306450ab 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -196,8 +196,8 @@ private static double getClosestSnappedTime(TimingControlPoint timingPoint, doub /// The time to find the control point at. /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. - protected T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) - where T : ControlPoint + public static T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) + where T : class, IControlPoint { return BinarySearch(list, time) ?? fallback; } @@ -207,9 +207,9 @@ protected T BinarySearchWithFallback(IReadOnlyList list, double time, T fa /// /// The list to search. /// The time to find the control point at. - /// The active control point at . - protected virtual T BinarySearch(IReadOnlyList list, double time) - where T : ControlPoint + /// The active control point at . Will return null if there are no control points, or if the time is before the first control point. + public static T BinarySearch(IReadOnlyList list, double time) + where T : class, IControlPoint { if (list == null) throw new ArgumentNullException(nameof(list)); diff --git a/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs new file mode 100644 index 000000000000..091e99e0296e --- /dev/null +++ b/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps.ControlPoints +{ + public interface IControlPoint + { + /// + /// The time at which the control point takes effect. + /// + double Time { get; } + } +} diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 75500fbc4ea5..5f0a2a082453 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -355,6 +355,14 @@ private void handleEvent(string line) switch (type) { + case LegacyEventType.Sprite: + // Generally, the background is the first thing defined in a beatmap file. + // In some older beatmaps, it is not present and replaced by a storyboard-level background instead. + // Allow the first sprite (by file order) to act as the background in such cases. + if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]); + break; + case LegacyEventType.Background: beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); break; @@ -427,8 +435,10 @@ private void handleTimingPoint(string line) addControlPoint(time, controlPoint, true); } + int onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID; + #pragma warning disable 618 - addControlPoint(time, new LegacyDifficultyControlPoint(beatLength) + addControlPoint(time, new LegacyDifficultyControlPoint(onlineRulesetID, beatLength) #pragma warning restore 618 { SliderVelocity = speedMultiplier, @@ -440,8 +450,6 @@ private void handleTimingPoint(string line) OmitFirstBarLine = omitFirstBarSignature, }; - int onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID; - // osu!taiko and osu!mania use effect points rather than difficulty points for scroll speed adjustments. if (onlineRulesetID == 1 || onlineRulesetID == 3) effectPoint.ScrollSpeed = speedMultiplier; diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 9c066ada0848..ed7ca47cfd1e 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -174,11 +174,15 @@ public class LegacyDifficultyControlPoint : DifficultyControlPoint, IEquatable public bool GenerateTicks { get; private set; } = true; - public LegacyDifficultyControlPoint(double beatLength) + public LegacyDifficultyControlPoint(int rulesetId, double beatLength) : this() { // Note: In stable, the division occurs on floats, but with compiler optimisations turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?). - BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100.0 : 1; + if (rulesetId == 1 || rulesetId == 3) + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100.0 : 1; + else + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 1000) / 100.0 : 1; + GenerateTicks = !double.IsNaN(beatLength); } diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index a39766abe182..0f0e72b0ac73 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -134,6 +134,6 @@ public interface IWorkingBeatmap /// /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. /// - void PrepareTrackForPreview(bool looping); + void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0); } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 0a51c843cd7d..393c4ba892f6 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -110,7 +110,7 @@ public void CancelAsyncLoad() public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000); - public void PrepareTrackForPreview(bool looping) + public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0) { Track.Looping = looping; Track.RestartPoint = Metadata.PreviewTime; @@ -125,6 +125,8 @@ public void PrepareTrackForPreview(bool looping) Track.RestartPoint = 0.4f * Track.Length; } + + Track.RestartPoint += offsetFromPreviewPoint; } /// diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index e3bfb6b1e976..1378e1691ae5 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -120,7 +120,7 @@ protected override void InitialiseDefaults() // Gameplay SetDefault(OsuSetting.PositionalHitsounds, true); // replaced by level setting below, can be removed 20220703. SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1); - SetDefault(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); + SetDefault(OsuSetting.DimLevel, 0.7, 0, 1, 0.01); SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); SetDefault(OsuSetting.LightenDuringBreaks, true); diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 877c90a534a7..6cba8fe819de 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -45,7 +45,7 @@ protected ModelDownloader(IModelImporter importer, IAPIProvider? api) public bool Download(T model, bool minimiseDownloadSize = false) => Download(model, minimiseDownloadSize, null); - public void DownloadAsUpdate(TModel originalModel) => Download(originalModel, false, originalModel); + public void DownloadAsUpdate(TModel originalModel, bool minimiseDownloadSize) => Download(originalModel, minimiseDownloadSize, originalModel); protected bool Download(T model, bool minimiseDownloadSize, TModel? originalModel) { diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index f1bc0bfe0ec8..0286815569c0 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -294,15 +294,38 @@ await Task.WhenAll(tasks.Select(async task => // Log output here will be missing a valid hash in non-batch imports. LogForModel(item, $@"Beginning import from {archive?.Name ?? "unknown"}..."); - // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. + List files = new List(); + + if (archive != null) + { + // Import files to the disk store. + // We intentionally delay adding to realm to avoid blocking on a write during disk operations. + foreach (var filenames in getShortenedFilenames(archive)) + { + using (Stream s = archive.GetStream(filenames.original)) + files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false), filenames.shortened)); + } + } + using (var transaction = realm.BeginWrite()) { - if (archive != null) - // TODO: look into rollback of file additions (or delayed commit). - item.Files.AddRange(createFileInfos(archive, Files, realm)); + // Add all files to realm in one go. + // This is done ahead of the main transaction to ensure we can correctly cleanup the files, even if the import fails. + foreach (var file in files) + { + if (!file.File.IsManaged) + realm.Add(file.File, true); + } + + transaction.Commit(); + } - item.Hash = ComputeHash(item); + item.Files.AddRange(files); + item.Hash = ComputeHash(item); + // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. + using (var transaction = realm.BeginWrite()) + { // TODO: we may want to run this outside of the transaction. Populate(item, archive, realm, cancellationToken); @@ -425,16 +448,6 @@ private List createFileInfos(ArchiveReader reader, RealmFil { var fileInfos = new List(); - // import files to manager - foreach (var filenames in getShortenedFilenames(reader)) - { - using (Stream s = reader.GetStream(filenames.original)) - { - var item = new RealmNamedFileUsage(files.Add(s, realm), filenames.shortened); - fileInfos.Add(item); - } - } - return fileInfos; } diff --git a/osu.Game/Database/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs index ecb152c45cf8..036b15ea17e0 100644 --- a/osu.Game/Database/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -40,8 +40,8 @@ public RealmFileStore(RealmAccess realm, Storage storage) /// /// The file data stream. /// The realm instance to add to. Should already be in a transaction. - /// - public RealmFile Add(Stream data, Realm realm) + /// Whether the should immediately be added to the underlying realm. If false is provided here, the instance must be manually added. + public RealmFile Add(Stream data, Realm realm, bool addToRealm = true) { string hash = data.ComputeSHA2Hash(); @@ -52,7 +52,7 @@ public RealmFile Add(Stream data, Realm realm) if (!checkFileExistsAndMatchesHash(file)) copyToStore(file, data); - if (!file.IsManaged) + if (addToRealm && !file.IsManaged) realm.Add(file); return file; diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs index 62fefe201db8..3855ed6d4eaa 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs @@ -5,6 +5,7 @@ using Markdig; using Markdig.Extensions.AutoLinks; +using Markdig.Extensions.CustomContainers; using Markdig.Extensions.EmphasisExtras; using Markdig.Extensions.Footnotes; using Markdig.Extensions.Tables; @@ -32,6 +33,12 @@ public class OsuMarkdownContainer : MarkdownContainer /// protected virtual bool Autolinks => false; + /// + /// Allows this markdown container to parse custom containers (used for flags and infoboxes). + /// + /// + protected virtual bool CustomContainers => false; + public OsuMarkdownContainer() { LineSpacing = 21; @@ -107,6 +114,9 @@ protected override MarkdownPipeline CreateBuilder() if (Autolinks) pipeline = pipeline.UseAutoLinks(); + if (CustomContainers) + pipeline.UseCustomContainers(); + return pipeline.Build(); } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs index db8abfb26906..9d7b47281f75 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs @@ -3,6 +3,9 @@ #nullable disable +using System; +using System.Linq; +using Markdig.Extensions.CustomContainers; using Markdig.Syntax.Inlines; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,6 +14,9 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Graphics.Containers.Markdown { @@ -33,6 +39,31 @@ protected override void AddCodeInLine(CodeInline codeInline) => AddDrawable(new protected override SpriteText CreateEmphasisedSpriteText(bool bold, bool italic) => CreateSpriteText().With(t => t.Font = t.Font.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, italics: italic)); + protected override void AddCustomComponent(CustomContainerInline inline) + { + if (!(inline.FirstChild is LiteralInline literal)) + { + base.AddCustomComponent(inline); + return; + } + + string[] attributes = literal.Content.ToString().Trim(' ', '{', '}').Split(); + string flagAttribute = attributes.SingleOrDefault(a => a.StartsWith(@"flag", StringComparison.Ordinal)); + + if (flagAttribute == null) + { + base.AddCustomComponent(inline); + return; + } + + string flag = flagAttribute.Split('=').Last().Trim('"'); + + if (!Enum.TryParse(flag, out var countryCode)) + countryCode = CountryCode.Unknown; + + AddDrawable(new DrawableFlag(countryCode) { Size = new Vector2(20, 15) }); + } + private class OsuMarkdownInlineCode : Container { [Resolved] diff --git a/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs index 6613e18cbee1..f5429723be8c 100644 --- a/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs +++ b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.Cursor { /// - /// A container which provides the main . + /// A container which provides the main . /// Also handles cases where a more localised cursor is provided by another component (via ). /// public class GlobalCursorDisplay : Container, IProvideCursor @@ -23,7 +23,9 @@ public class GlobalCursorDisplay : Container, IProvideCursor /// internal bool ShowCursor = true; - public CursorContainer MenuCursor { get; } + CursorContainer IProvideCursor.Cursor => MenuCursor; + + public MenuCursorContainer MenuCursor { get; } public bool ProvidingUserCursor => true; @@ -42,8 +44,8 @@ public GlobalCursorDisplay() { AddRangeInternal(new Drawable[] { - MenuCursor = new MenuCursor { State = { Value = Visibility.Hidden } }, - Content = new Container { RelativeSizeAxes = Axes.Both } + Content = new Container { RelativeSizeAxes = Axes.Both }, + MenuCursor = new MenuCursorContainer { State = { Value = Visibility.Hidden } } }); } @@ -64,7 +66,7 @@ protected override void Update() if (!hasValidInput || !ShowCursor) { - currentOverrideProvider?.MenuCursor?.Hide(); + currentOverrideProvider?.Cursor?.Hide(); currentOverrideProvider = null; return; } @@ -83,8 +85,8 @@ protected override void Update() if (currentOverrideProvider == newOverrideProvider) return; - currentOverrideProvider?.MenuCursor?.Hide(); - newOverrideProvider.MenuCursor?.Show(); + currentOverrideProvider?.Cursor?.Hide(); + newOverrideProvider.Cursor?.Show(); currentOverrideProvider = newOverrideProvider; } diff --git a/osu.Game/Graphics/Cursor/IProvideCursor.cs b/osu.Game/Graphics/Cursor/IProvideCursor.cs index f7f7b75bc839..9f01e5da6d63 100644 --- a/osu.Game/Graphics/Cursor/IProvideCursor.cs +++ b/osu.Game/Graphics/Cursor/IProvideCursor.cs @@ -17,10 +17,10 @@ public interface IProvideCursor : IDrawable /// The cursor provided by this . /// May be null if no cursor should be visible. /// - CursorContainer MenuCursor { get; } + CursorContainer Cursor { get; } /// - /// Whether should be displayed as the singular user cursor. This will temporarily hide any other user cursor. + /// Whether should be displayed as the singular user cursor. This will temporarily hide any other user cursor. /// This value is checked every frame and may be used to control whether multiple cursors are displayed (e.g. watching replays). /// bool ProvidingUserCursor { get; } diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs similarity index 69% rename from osu.Game/Graphics/Cursor/MenuCursor.cs rename to osu.Game/Graphics/Cursor/MenuCursorContainer.cs index 862a10208c5a..af542989ff9c 100644 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -21,24 +18,43 @@ namespace osu.Game.Graphics.Cursor { - public class MenuCursor : CursorContainer + public class MenuCursorContainer : CursorContainer { private readonly IBindable screenshotCursorVisibility = new Bindable(true); public override bool IsPresent => screenshotCursorVisibility.Value && base.IsPresent; + private bool hideCursorOnNonMouseInput; + + public bool HideCursorOnNonMouseInput + { + get => hideCursorOnNonMouseInput; + set + { + if (hideCursorOnNonMouseInput == value) + return; + + hideCursorOnNonMouseInput = value; + updateState(); + } + } + protected override Drawable CreateCursor() => activeCursor = new Cursor(); - private Cursor activeCursor; + private Cursor activeCursor = null!; - private Bindable cursorRotate; private DragRotationState dragRotationState; private Vector2 positionMouseDown; - - private Sample tapSample; private Vector2 lastMovePosition; - [BackgroundDependencyLoader(true)] - private void load([NotNull] OsuConfigManager config, [CanBeNull] ScreenshotManager screenshotManager, AudioManager audio) + private Bindable cursorRotate = null!; + private Sample tapSample = null!; + + private MouseInputDetector mouseInputDetector = null!; + + private bool visible; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, ScreenshotManager? screenshotManager, AudioManager audio) { cursorRotate = config.GetBindable(OsuSetting.CursorRotation); @@ -46,6 +62,45 @@ private void load([NotNull] OsuConfigManager config, [CanBeNull] ScreenshotManag screenshotCursorVisibility.BindTo(screenshotManager.CursorVisibility); tapSample = audio.Samples.Get(@"UI/cursor-tap"); + + Add(mouseInputDetector = new MouseInputDetector()); + } + + [Resolved] + private OsuGame? game { get; set; } + + private readonly IBindable lastInputWasMouse = new BindableBool(); + private readonly IBindable isIdle = new BindableBool(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + lastInputWasMouse.BindTo(mouseInputDetector.LastInputWasMouseSource); + lastInputWasMouse.BindValueChanged(_ => updateState(), true); + + if (game != null) + { + isIdle.BindTo(game.IsIdle); + isIdle.BindValueChanged(_ => updateState()); + } + } + + protected override void UpdateState(ValueChangedEvent state) => updateState(); + + private void updateState() + { + bool combinedVisibility = State.Value == Visibility.Visible && (lastInputWasMouse.Value || !hideCursorOnNonMouseInput) && !isIdle.Value; + + if (visible == combinedVisibility) + return; + + visible = combinedVisibility; + + if (visible) + PopIn(); + else + PopOut(); } protected override void Update() @@ -163,11 +218,11 @@ private void playTapSample(double baseFrequency = 1f) public class Cursor : Container { - private Container cursorContainer; - private Bindable cursorScale; + private Container cursorContainer = null!; + private Bindable cursorScale = null!; private const float base_scale = 0.15f; - public Sprite AdditiveLayer; + public Sprite AdditiveLayer = null!; public Cursor() { @@ -204,6 +259,40 @@ private void load(OsuConfigManager config, TextureStore textures, OsuColour colo } } + private class MouseInputDetector : Component + { + /// + /// Whether the last input applied to the game is sourced from mouse. + /// + public IBindable LastInputWasMouseSource => lastInputWasMouseSource; + + private readonly Bindable lastInputWasMouseSource = new Bindable(); + + public MouseInputDetector() + { + RelativeSizeAxes = Axes.Both; + } + + protected override bool Handle(UIEvent e) + { + switch (e) + { + case MouseDownEvent: + case MouseMoveEvent: + lastInputWasMouseSource.Value = true; + return false; + + case KeyDownEvent keyDown when !keyDown.Repeat: + case JoystickPressEvent: + case MidiDownEvent: + lastInputWasMouseSource.Value = false; + return false; + } + + return false; + } + } + private enum DragRotationState { NotDragging, diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 7f86a060ade3..e51dbeed14e8 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Game.Localisation; using osu.Framework.Platform; using osu.Game.Overlays; using osu.Game.Overlays.OSD; @@ -94,15 +93,7 @@ public MenuItem[] ContextMenuItems private void copyUrl() { host.GetClipboard()?.SetText(Link); - onScreenDisplay?.Display(new CopyUrlToast(ToastStrings.UrlCopied)); - } - - private class CopyUrlToast : Toast - { - public CopyUrlToast(LocalisableString value) - : base(UserInterfaceStrings.GeneralHeader, value, "") - { - } + onScreenDisplay?.Display(new CopyUrlToast()); } } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index 6358317e9d27..f0ff76b35d7f 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -15,6 +15,9 @@ public enum HoverSampleSet [Description("button")] Button, + [Description("button-sidebar")] + ButtonSidebar, + [Description("toolbar")] Toolbar, diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index bbd8f8eceab9..8772c1e2d91a 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -26,24 +26,24 @@ public LocalisableString LabelText { set { - if (labelText != null) - labelText.Text = value; + if (LabelTextFlowContainer != null) + LabelTextFlowContainer.Text = value; } } public MarginPadding LabelPadding { - get => labelText?.Padding ?? new MarginPadding(); + get => LabelTextFlowContainer?.Padding ?? new MarginPadding(); set { - if (labelText != null) - labelText.Padding = value; + if (LabelTextFlowContainer != null) + LabelTextFlowContainer.Padding = value; } } protected readonly Nub Nub; - private readonly OsuTextFlowContainer labelText; + protected readonly OsuTextFlowContainer LabelTextFlowContainer; private Sample sampleChecked; private Sample sampleUnchecked; @@ -56,7 +56,7 @@ public OsuCheckbox(bool nubOnRight = true) Children = new Drawable[] { - labelText = new OsuTextFlowContainer(ApplyLabelParameters) + LabelTextFlowContainer = new OsuTextFlowContainer(ApplyLabelParameters) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -70,19 +70,19 @@ public OsuCheckbox(bool nubOnRight = true) Nub.Anchor = Anchor.CentreRight; Nub.Origin = Anchor.CentreRight; Nub.Margin = new MarginPadding { Right = nub_padding }; - labelText.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 }; + LabelTextFlowContainer.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 }; } else { Nub.Anchor = Anchor.CentreLeft; Nub.Origin = Anchor.CentreLeft; Nub.Margin = new MarginPadding { Left = nub_padding }; - labelText.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 }; + LabelTextFlowContainer.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 }; } Nub.Current.BindTo(Current); - Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1; + Current.DisabledChanged += disabled => LabelTextFlowContainer.Alpha = Nub.Alpha = disabled ? 0.3f : 1; } /// diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 2a8b41fd209e..9acb0c7f9418 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -44,6 +44,8 @@ public class OsuSliderBar : SliderBar, IHasTooltip, IHasAccentColour public virtual LocalisableString TooltipText { get; private set; } + public bool PlaySamplesOnAdjust { get; set; } = true; + /// /// Whether to format the tooltip as a percentage or the actual value. /// @@ -187,6 +189,9 @@ protected override void OnUserChange(T value) private void playSample(T value) { + if (!PlaySamplesOnAdjust) + return; + if (Clock == null || Clock.CurrentTime - lastSampleTime <= 30) return; diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs index 42e1073baf0c..0e348108aa8b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs @@ -31,6 +31,8 @@ private void load() protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); + protected override Drawable CreateHiddenToggleButton() => new OsuDirectorySelectorHiddenToggle { Current = { BindTarget = ShowHiddenItems } }; + protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs new file mode 100644 index 000000000000..7aaf12ca34d6 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs @@ -0,0 +1,38 @@ +// 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.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + internal class OsuDirectorySelectorHiddenToggle : OsuCheckbox + { + public OsuDirectorySelectorHiddenToggle() + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.None; + Size = new Vector2(100, 50); + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + LabelTextFlowContainer.Anchor = Anchor.CentreLeft; + LabelTextFlowContainer.Origin = Anchor.CentreLeft; + LabelText = @"Show hidden"; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + if (overlayColourProvider != null) + return; + + Nub.AccentColour = colours.GreySeaFoamLighter; + Nub.GlowingAccentColour = Color4.White; + Nub.GlowColour = Color4.White; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index 3e8b7dc209e0..70af68d595aa 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -33,6 +33,8 @@ private void load() protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); + protected override Drawable CreateHiddenToggleButton() => new OsuDirectorySelectorHiddenToggle { Current = { BindTarget = ShowHiddenItems } }; + protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index 14a041b4598d..4f079ab435b0 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -55,13 +55,13 @@ protected override void LoadComplete() { // The first fire of this is a bit redundant as this is being called in base.LoadComplete, // but this is safest in case the subscription is restored after a context recycle. - reloadMappings(sender.AsQueryable()); + ReloadMappings(sender.AsQueryable()); }); base.LoadComplete(); } - protected override void ReloadMappings() => reloadMappings(queryRealmKeyBindings(realm.Realm)); + protected sealed override void ReloadMappings() => ReloadMappings(queryRealmKeyBindings(realm.Realm)); private IQueryable queryRealmKeyBindings(Realm realm) { @@ -70,7 +70,7 @@ private IQueryable queryRealmKeyBindings(Realm realm) .Where(b => b.RulesetName == rulesetName && b.Variant == variant); } - private void reloadMappings(IQueryable realmKeyBindings) + protected virtual void ReloadMappings(IQueryable realmKeyBindings) { var defaults = DefaultKeyBindings.ToList(); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index ad53f6d90fae..a58c6723efd8 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -31,14 +31,17 @@ protected override void LoadComplete() parentInputManager = GetContainingInputManager(); } - // IMPORTANT: Do not change the order of key bindings in this list. - // It is used to decide the order of precedence (see note in DatabasedKeyBindingContainer). + // IMPORTANT: Take care when changing order of the items in the enumerable. + // It is used to decide the order of precedence, with the earlier items having higher precedence. public override IEnumerable DefaultKeyBindings => GlobalKeyBindings - .Concat(OverlayKeyBindings) .Concat(EditorKeyBindings) .Concat(InGameKeyBindings) .Concat(SongSelectKeyBindings) - .Concat(AudioControlKeyBindings); + .Concat(AudioControlKeyBindings) + // Overlay bindings may conflict with more local cases like the editor so they are checked last. + // It has generally been agreed on that local screens like the editor should have priority, + // based on such usages potentially requiring a lot more key bindings that may be "shared" with global ones. + .Concat(OverlayKeyBindings); public IEnumerable GlobalKeyBindings => new[] { @@ -87,6 +90,7 @@ protected override void LoadComplete() new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode), new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), + new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.EditorCloneSelection), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), @@ -343,5 +347,8 @@ public enum GlobalAction [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleProfile))] ToggleProfile, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCloneSelection))] + EditorCloneSelection } } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 93e3276f5938..385ebd05933c 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -89,6 +89,11 @@ public static class CommonStrings /// public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Collections"); + /// + /// "Mod presets" + /// + public static LocalisableString ModPresets => new TranslatableString(getKey(@"mod_presets"), @"Mod presets"); + /// /// "Name" /// diff --git a/osu.Game/Localisation/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs index 74b2c8d89231..dd2173909611 100644 --- a/osu.Game/Localisation/DebugSettingsStrings.cs +++ b/osu.Game/Localisation/DebugSettingsStrings.cs @@ -44,11 +44,6 @@ public static class DebugSettingsStrings /// public static LocalisableString ClearAllCaches => new TranslatableString(getKey(@"clear_all_caches"), @"Clear all caches"); - /// - /// "Compact realm" - /// - public static LocalisableString CompactRealm => new TranslatableString(getKey(@"compact_realm"), @"Compact realm"); - private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 2aa91f5245f4..3278b2098367 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -64,6 +64,11 @@ public static class GeneralSettingsStrings /// public static LocalisableString RunSetupWizard => new TranslatableString(getKey(@"run_setup_wizard"), @"Run setup wizard"); + /// + /// "You are running the latest release ({0})" + /// + public static LocalisableString RunningLatestRelease(string version) => new TranslatableString(getKey(@"running_latest_release"), @"You are running the latest release ({0})", version); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 172818c1c09d..14e0bbbceda9 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -184,6 +184,11 @@ public static class GlobalActionKeyBindingStrings /// public static LocalisableString EditorTapForBPM => new TranslatableString(getKey(@"editor_tap_for_bpm"), @"Tap for BPM"); + /// + /// "Clone selection" + /// + public static LocalisableString EditorCloneSelection => new TranslatableString(getKey(@"editor_clone_selection"), @"Clone selection"); + /// /// "Cycle grid display mode" /// diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs index a398eced071f..8aa0adf7a0ef 100644 --- a/osu.Game/Localisation/MaintenanceSettingsStrings.cs +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -19,6 +19,41 @@ public static class MaintenanceSettingsStrings /// public static LocalisableString SelectDirectory => new TranslatableString(getKey(@"select_directory"), @"Select directory"); + /// + /// "Migration in progress" + /// + public static LocalisableString MigrationInProgress => new TranslatableString(getKey(@"migration_in_progress"), @"Migration in progress"); + + /// + /// "This could take a few minutes depending on the speed of your disk(s)." + /// + public static LocalisableString MigrationDescription => new TranslatableString(getKey(@"migration_description"), @"This could take a few minutes depending on the speed of your disk(s)."); + + /// + /// "Please avoid interacting with the game!" + /// + public static LocalisableString ProhibitedInteractDuringMigration => new TranslatableString(getKey(@"prohibited_interact_during_migration"), @"Please avoid interacting with the game!"); + + /// + /// "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up." + /// + public static LocalisableString FailedCleanupNotification => new TranslatableString(getKey(@"failed_cleanup_notification"), @"Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up."); + + /// + /// "Please select a new location" + /// + public static LocalisableString SelectNewLocation => new TranslatableString(getKey(@"select_new_location"), @"Please select a new location"); + + /// + /// "The target directory already seems to have an osu! install. Use that data instead?" + /// + public static LocalisableString TargetDirectoryAlreadyInstalledOsu => new TranslatableString(getKey(@"target_directory_already_installed_osu"), @"The target directory already seems to have an osu! install. Use that data instead?"); + + /// + /// "To complete this operation, osu! will close. Please open it again to use the new data location." + /// + public static LocalisableString RestartAndReOpenRequiredForCompletion => new TranslatableString(getKey(@"restart_and_re_open_required_for_completion"), @"To complete this operation, osu! will close. Please open it again to use the new data location."); + /// /// "Import beatmaps from stable" /// @@ -84,6 +119,26 @@ public static class MaintenanceSettingsStrings /// public static LocalisableString RestoreAllRecentlyDeletedModPresets => new TranslatableString(getKey(@"restore_all_recently_deleted_mod_presets"), @"Restore all recently deleted mod presets"); + /// + /// "Deleted all collections!" + /// + public static LocalisableString DeletedAllCollections => new TranslatableString(getKey(@"deleted_all_collections"), @"Deleted all collections!"); + + /// + /// "Deleted all mod presets!" + /// + public static LocalisableString DeletedAllModPresets => new TranslatableString(getKey(@"deleted_all_mod_presets"), @"Deleted all mod presets!"); + + /// + /// "Restored all deleted mod presets!" + /// + public static LocalisableString RestoredAllDeletedModPresets => new TranslatableString(getKey(@"restored_all_deleted_mod_presets"), @"Restored all deleted mod presets!"); + + /// + /// "Please select your osu!stable install location" + /// + public static LocalisableString StableDirectorySelectHeader => new TranslatableString(getKey(@"stable_directory_select_header"), @"Please select your osu!stable install location"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/PopupDialogStrings.cs b/osu.Game/Localisation/PopupDialogStrings.cs new file mode 100644 index 000000000000..b2e9673cbee1 --- /dev/null +++ b/osu.Game/Localisation/PopupDialogStrings.cs @@ -0,0 +1,24 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class PopupDialogStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.PopupDialog"; + + /// + /// "Are you sure you want to update this beatmap?" + /// + public static LocalisableString UpdateLocallyModifiedText => new TranslatableString(getKey(@"update_locally_modified_text"), @"Are you sure you want to update this beatmap?"); + + /// + /// "This will discard all local changes you have on that beatmap." + /// + public static LocalisableString UpdateLocallyModifiedDescription => new TranslatableString(getKey(@"update_locally_modified_description"), @"This will discard all local changes you have on that beatmap."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs index d62d348df944..6c2e3c1f9c69 100644 --- a/osu.Game/Localisation/TabletSettingsStrings.cs +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -19,6 +19,11 @@ public static class TabletSettingsStrings /// public static LocalisableString NoTabletDetected => new TranslatableString(getKey(@"no_tablet_detected"), @"No tablet detected!"); + /// + /// "If your tablet is not detected, please read [this FAQ]({0}) for troubleshooting steps." + /// + public static LocalisableString NoTabletDetectedDescription(string url) => new TranslatableString(getKey(@"no_tablet_detected_description"), @"If your tablet is not detected, please read [this FAQ]({0}) for troubleshooting steps.", url); + /// /// "Reset to full area" /// diff --git a/osu.Game/Online/API/Requests/CommentDeleteRequest.cs b/osu.Game/Online/API/Requests/CommentDeleteRequest.cs new file mode 100644 index 000000000000..b150a6d5fc93 --- /dev/null +++ b/osu.Game/Online/API/Requests/CommentDeleteRequest.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class CommentDeleteRequest : APIRequest + { + public readonly long CommentId; + + public CommentDeleteRequest(long id) + { + CommentId = id; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"comments/{CommentId}"; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs index 500c0566e65c..907632186c98 100644 --- a/osu.Game/Online/API/Requests/Responses/Comment.cs +++ b/osu.Game/Online/API/Requests/Responses/Comment.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using Newtonsoft.Json; using System; @@ -16,18 +14,18 @@ public class Comment [JsonProperty(@"parent_id")] public long? ParentId { get; set; } - public Comment ParentComment { get; set; } + public Comment? ParentComment { get; set; } [JsonProperty(@"user_id")] public long? UserId { get; set; } - public APIUser User { get; set; } + public APIUser? User { get; set; } [JsonProperty(@"message")] - public string Message { get; set; } + public string Message { get; set; } = null!; [JsonProperty(@"message_html")] - public string MessageHtml { get; set; } + public string? MessageHtml { get; set; } [JsonProperty(@"replies_count")] public int RepliesCount { get; set; } @@ -36,13 +34,13 @@ public class Comment public int VotesCount { get; set; } [JsonProperty(@"commenatble_type")] - public string CommentableType { get; set; } + public string CommentableType { get; set; } = null!; [JsonProperty(@"commentable_id")] public int CommentableId { get; set; } [JsonProperty(@"legacy_name")] - public string LegacyName { get; set; } + public string? LegacyName { get; set; } [JsonProperty(@"created_at")] public DateTimeOffset CreatedAt { get; set; } @@ -62,7 +60,7 @@ public class Comment [JsonProperty(@"pinned")] public bool Pinned { get; set; } - public APIUser EditedUser { get; set; } + public APIUser? EditedUser { get; set; } public bool IsTopLevel => !ParentId.HasValue; diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index 2c9f2500280c..57586e3d7777 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -44,7 +44,8 @@ public class SoloScoreInfo : IHasOnlineID public int MaxCombo { get; set; } [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("rank")] + // ScoreRank is aligned to make 0 equal D. We still want to serialise this (even when DefaultValueHandling.Ignore is used). + [JsonProperty("rank", DefaultValueHandling = DefaultValueHandling.Include)] public ScoreRank Rank { get; set; } [JsonProperty("started_at")] @@ -114,6 +115,7 @@ public APIBeatmapSet? BeatmapSet [JsonProperty("has_replay")] public bool HasReplay { get; set; } + // These properties are calculated or not relevant to any external usage. public bool ShouldSerializeID() => false; public bool ShouldSerializeUser() => false; public bool ShouldSerializeBeatmap() => false; @@ -122,6 +124,18 @@ public APIBeatmapSet? BeatmapSet public bool ShouldSerializeOnlineID() => false; public bool ShouldSerializeHasReplay() => false; + // These fields only need to be serialised if they hold values. + // Generally this is required because this model may be used by server-side components, but + // we don't want to bother sending these fields in score submission requests, for instance. + public bool ShouldSerializeEndedAt() => EndedAt != default; + public bool ShouldSerializeStartedAt() => StartedAt != default; + public bool ShouldSerializeLegacyScoreId() => LegacyScoreId != null; + public bool ShouldSerializeLegacyTotalScore() => LegacyTotalScore != null; + public bool ShouldSerializeMods() => Mods.Length > 0; + public bool ShouldSerializeUserID() => UserID > 0; + public bool ShouldSerializeBeatmapID() => BeatmapID > 0; + public bool ShouldSerializeBuildID() => BuildID != null; + #endregion public override string ToString() => $"score_id: {ID} user_id: {UserID}"; diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 6f597e5b101b..d5e0c7a9703b 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -65,7 +65,7 @@ public class MultiplayerScore [CanBeNull] public MultiplayerScoresAround ScoresAround { get; set; } - public ScoreInfo CreateScoreInfo(RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) + public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) { var ruleset = rulesets.GetRuleset(playlistItem.RulesetID); if (ruleset == null) @@ -90,6 +90,8 @@ public ScoreInfo CreateScoreInfo(RulesetStore rulesets, PlaylistItem playlistIte Position = Position, }; + scoreManager.PopulateMaximumStatistics(scoreInfo); + return scoreInfo; } } diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index a012bf49b688..48d5c0bea980 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Diagnostics; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -58,7 +58,7 @@ protected override async Task BeginPlayingInternal(SpectatorState state) { await connection.InvokeAsync(nameof(ISpectatorServer.BeginPlaySession), state); } - catch (HubException exception) + catch (Exception exception) { if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) { diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 59c0af1533e7..2bdcb57f2a2c 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -70,6 +70,7 @@ namespace osu.Game /// The full osu! experience. Builds on top of to add menus and binding logic /// for initial components that are generally retrieved via DI. /// + [Cached(typeof(OsuGame))] public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager, ILinkHandler { /// @@ -136,6 +137,11 @@ public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUser private IdleTracker idleTracker; + /// + /// Whether the user is currently in an idle state. + /// + public IBindable IsIdle => idleTracker.IsIdle; + /// /// Whether overlays should be able to be opened game-wide. Value is sourced from the current active screen. /// @@ -266,8 +272,6 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl [BackgroundDependencyLoader] private void load() { - dependencies.CacheAs(this); - SentryLogger.AttachUser(API.LocalUser); dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); @@ -561,9 +565,20 @@ public void PresentScore(IScoreInfo score, ScorePresentType presentType = ScoreP return; } + // This should be able to be performed from song select, but that is disabled for now + // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). + // + // As a special case, if the beatmap and ruleset already match, allow immediately displaying the score from song select. + // This is guaranteed to not crash, and feels better from a user's perspective (ie. if they are clicking a score in the + // song select leaderboard). + IEnumerable validScreens = + Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset) + ? new[] { typeof(SongSelect) } + : Array.Empty(); + PerformFromScreen(screen => { - Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset} to match score"); + Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset}) to match score"); Ruleset.Value = databasedScore.ScoreInfo.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); @@ -578,7 +593,7 @@ public void PresentScore(IScoreInfo score, ScorePresentType presentType = ScoreP screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo, false)); break; } - }, validScreens: new[] { typeof(PlaySongSelect) }); + }, validScreens: validScreens); } public override Task Import(params ImportTask[] imports) @@ -1318,6 +1333,8 @@ private void screenChanged(IScreen current, IScreen newScreen) OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); API.Activity.BindTo(newOsuScreen.Activity); + GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newOsuScreen.HideMenuCursorOnNonMouseInput; + if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); else diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 478f154d582a..7d9ed7bf3e39 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -62,6 +62,7 @@ namespace osu.Game /// Unlike , this class will not load any kind of UI, allowing it to be used /// for provide dependencies to test cases without interfering with them. /// + [Cached(typeof(OsuGameBase))] public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv" }; @@ -253,7 +254,6 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider) largeStore.AddTextureSource(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); - dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); InitialiseFonts(); diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 2be328427be6..c73936da8a98 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -115,6 +115,7 @@ public void ShowWithSearch(string query) { filterControl.Search(query); Show(); + ScrollFlow.ScrollToStart(); } protected override BeatmapListingHeader CreateHeader() => new BeatmapListingHeader(); diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 296320ec1bab..0675d99a2542 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -22,7 +20,13 @@ using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Overlays.Comments.Buttons; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.OSD; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments @@ -31,7 +35,7 @@ public class DrawableComment : CompositeDrawable { private const int avatar_size = 40; - public Action RepliesRequested; + public Action RepliesRequested = null!; public readonly Comment Comment; @@ -45,13 +49,35 @@ public class DrawableComment : CompositeDrawable private int currentPage; - private FillFlowContainer childCommentsVisibilityContainer; - private FillFlowContainer childCommentsContainer; - private LoadRepliesButton loadRepliesButton; - private ShowMoreRepliesButton showMoreButton; - private ShowRepliesButton showRepliesButton; - private ChevronButton chevronButton; - private DeletedCommentsCounter deletedCommentsCounter; + /// + /// Local field for tracking comment state. Initialized from Comment.IsDeleted, may change when deleting was requested by user. + /// + public bool WasDeleted { get; protected set; } + + private FillFlowContainer childCommentsVisibilityContainer = null!; + private FillFlowContainer childCommentsContainer = null!; + private LoadRepliesButton loadRepliesButton = null!; + private ShowMoreRepliesButton showMoreButton = null!; + private ShowRepliesButton showRepliesButton = null!; + private ChevronButton chevronButton = null!; + private LinkFlowContainer actionsContainer = null!; + private LoadingSpinner actionsLoading = null!; + private DeletedCommentsCounter deletedCommentsCounter = null!; + private OsuSpriteText deletedLabel = null!; + private GridContainer content = null!; + private VotePill votePill = null!; + + [Resolved(canBeNull: true)] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved(canBeNull: true)] + private OnScreenDisplay? onScreenDisplay { get; set; } public DrawableComment(Comment comment) { @@ -64,8 +90,6 @@ private void load(OverlayColourProvider colourProvider) LinkFlowContainer username; FillFlowContainer info; CommentMarkdownContainer message; - GridContainer content; - VotePill votePill; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -148,9 +172,9 @@ private void load(OverlayColourProvider colourProvider) }, Comment.Pinned ? new PinnedCommentNotice() : Empty(), new ParentUsername(Comment), - new OsuSpriteText + deletedLabel = new OsuSpriteText { - Alpha = Comment.IsDeleted ? 1 : 0, + Alpha = 0f, Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), Text = CommentsStrings.Deleted } @@ -163,16 +187,36 @@ private void load(OverlayColourProvider colourProvider) DocumentMargin = new MarginPadding(0), DocumentPadding = new MarginPadding(0), }, - info = new FillFlowContainer + new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(10, 0), Children = new Drawable[] { - new DrawableDate(Comment.CreatedAt, 12, false) + info = new FillFlowContainer { - Colour = colourProvider.Foreground1 + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new DrawableDate(Comment.CreatedAt, 12, false) + { + Colour = colourProvider.Foreground1 + } + } + }, + actionsContainer = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold)) + { + Name = @"Actions buttons", + AutoSizeAxes = Axes.Both, + }, + actionsLoading = new LoadingSpinner + { + Size = new Vector2(12f), + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft } } }, @@ -246,9 +290,9 @@ private void load(OverlayColourProvider colourProvider) if (Comment.UserId.HasValue) username.AddUserLink(Comment.User); else - username.AddText(Comment.LegacyName); + username.AddText(Comment.LegacyName!); - if (Comment.EditedAt.HasValue) + if (Comment.EditedAt.HasValue && Comment.EditedUser != null) { var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); var colour = colourProvider.Foreground1; @@ -282,10 +326,16 @@ private void load(OverlayColourProvider colourProvider) if (Comment.HasMessage) message.Text = Comment.Message; - if (Comment.IsDeleted) + WasDeleted = Comment.IsDeleted; + if (WasDeleted) + makeDeleted(); + + actionsContainer.AddLink("Copy link", copyUrl); + actionsContainer.AddArbitraryDrawable(new Container { Width = 10 }); + + if (Comment.UserId.HasValue && Comment.UserId.Value == api.LocalUser.Value.Id) { - content.FadeColour(OsuColour.Gray(0.5f)); - votePill.Hide(); + actionsContainer.AddLink("Delete", deleteComment); } if (Comment.IsTopLevel) @@ -317,11 +367,63 @@ private void load(OverlayColourProvider colourProvider) }; } + /// + /// Transforms some comment's components to show it as deleted. Invoked both from loading and deleting. + /// + private void makeDeleted() + { + deletedLabel.Show(); + content.FadeColour(OsuColour.Gray(0.5f)); + votePill.Hide(); + actionsContainer.Expire(); + } + + /// + /// Invokes comment deletion with confirmation. + /// + private void deleteComment() + { + if (dialogOverlay == null) + deleteCommentRequest(); + else + dialogOverlay.Push(new ConfirmDialog("Do you really want to delete your comment?", deleteCommentRequest)); + } + + /// + /// Invokes comment deletion directly. + /// + private void deleteCommentRequest() + { + actionsContainer.Hide(); + actionsLoading.Show(); + var request = new CommentDeleteRequest(Comment.Id); + request.Success += _ => Schedule(() => + { + actionsLoading.Hide(); + makeDeleted(); + WasDeleted = true; + if (!ShowDeleted.Value) + Hide(); + }); + request.Failure += _ => Schedule(() => + { + actionsLoading.Hide(); + actionsContainer.Show(); + }); + api.Queue(request); + } + + private void copyUrl() + { + host.GetClipboard()?.SetText($@"{api.APIEndpointUrl}/comments/{Comment.Id}"); + onScreenDisplay?.Display(new CopyUrlToast()); + } + protected override void LoadComplete() { ShowDeleted.BindValueChanged(show => { - if (Comment.IsDeleted) + if (WasDeleted) this.FadeTo(show.NewValue ? 1 : 0); }, true); childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); @@ -425,7 +527,7 @@ private class ParentUsername : FillFlowContainer, IHasTooltip { public LocalisableString TooltipText => getParentMessage(); - private readonly Comment parentComment; + private readonly Comment? parentComment; public ParentUsername(Comment comment) { @@ -445,7 +547,7 @@ public ParentUsername(Comment comment) new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = parentComment?.User?.Username ?? parentComment?.LegacyName + Text = parentComment?.User?.Username ?? parentComment?.LegacyName! } }; } diff --git a/osu.Game/Overlays/Dialog/ConfirmDialog.cs b/osu.Game/Overlays/Dialog/ConfirmDialog.cs index 8be865ee168a..c17080f602db 100644 --- a/osu.Game/Overlays/Dialog/ConfirmDialog.cs +++ b/osu.Game/Overlays/Dialog/ConfirmDialog.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dialog @@ -20,7 +21,7 @@ public class ConfirmDialog : PopupDialog /// The description of the action to be displayed to the user. /// An action to perform on confirmation. /// An optional action to perform on cancel. - public ConfirmDialog(string message, Action onConfirm, Action onCancel = null) + public ConfirmDialog(LocalisableString message, Action onConfirm, Action onCancel = null) { HeaderText = message; BodyText = "Last chance to turn back"; diff --git a/osu.Game/Overlays/OSD/CopyUrlToast.cs b/osu.Game/Overlays/OSD/CopyUrlToast.cs new file mode 100644 index 000000000000..ea835a1c5e2c --- /dev/null +++ b/osu.Game/Overlays/OSD/CopyUrlToast.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Localisation; + +namespace osu.Game.Overlays.OSD +{ + public class CopyUrlToast : Toast + { + public CopyUrlToast() + : base(UserInterfaceStrings.GeneralHeader, ToastStrings.UrlCopied, "") + { + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index cea8fdd73302..8f188f04d9ef 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -21,7 +22,7 @@ private void load(AudioManager audio, OsuConfigManager config) { Children = new Drawable[] { - new SettingsSlider + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.MasterVolume, Current = audio.Volume, @@ -35,14 +36,15 @@ private void load(AudioManager audio, OsuConfigManager config) KeyboardStep = 0.01f, DisplayAsPercentage = true }, - new SettingsSlider + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.EffectVolume, Current = audio.VolumeSample, KeyboardStep = 0.01f, DisplayAsPercentage = true }, - new SettingsSlider + + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.MusicVolume, Current = audio.VolumeTrack, @@ -51,5 +53,15 @@ private void load(AudioManager audio, OsuConfigManager config) }, }; } + + private class VolumeAdjustSlider : SettingsSlider + { + protected override Drawable CreateControl() + { + var sliderBar = (OsuSliderBar)base.CreateControl(); + sliderBar.PlaySamplesOnAdjust = false; + return sliderBar; + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index 42ac4adb34cd..3afb060e4909 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -35,7 +35,7 @@ private void load(GameHost host, RealmAccess realm) }, new SettingsButton { - Text = DebugSettingsStrings.CompactRealm, + Text = "Compact realm", Action = () => { // Blocking operations implicitly causes a Compact(). diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 63f0dec9539d..0f77e6609bdf 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -44,9 +44,12 @@ private void load(FrameworkConfigManager frameworkConfig, OsuConfigManager confi }, }; - if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale)) - locale = Language.en; - languageSelection.Current.Value = locale; + frameworkLocale.BindValueChanged(locale => + { + if (!LanguageExtensions.TryParseCultureCode(locale.NewValue, out var language)) + language = Language.en; + languageSelection.Current.Value = language; + }, true); languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index b68a4fed48c8..d97cf699e5bf 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -54,7 +54,7 @@ private void load(Storage storage, OsuConfigManager config, OsuGame game) { notifications?.Post(new SimpleNotification { - Text = $"You are running the latest release ({game.Version})", + Text = GeneralSettingsStrings.RunningLatestRelease(game.Version), Icon = FontAwesome.Solid.CheckCircle, }); } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 28642f12a1b7..59b56522a45f 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -1,9 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; +using System.Collections.Generic; using System.Drawing; using System.Linq; using osu.Framework; @@ -29,37 +28,41 @@ public class LayoutSettings : SettingsSubsection { protected override LocalisableString Header => GraphicsSettingsStrings.LayoutHeader; - private FillFlowContainer> scalingSettings; + private FillFlowContainer> scalingSettings = null!; private readonly Bindable currentDisplay = new Bindable(); private readonly IBindableList windowModes = new BindableList(); - private Bindable scalingMode; - private Bindable sizeFullscreen; + private Bindable scalingMode = null!; + private Bindable sizeFullscreen = null!; private readonly BindableList resolutions = new BindableList(new[] { new Size(9999, 9999) }); private readonly IBindable fullscreenCapability = new Bindable(FullscreenCapability.Capable); [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; + + private IWindow? window; - private SettingsDropdown resolutionDropdown; - private SettingsDropdown displayDropdown; - private SettingsDropdown windowModeDropdown; + private SettingsDropdown resolutionDropdown = null!; + private SettingsDropdown displayDropdown = null!; + private SettingsDropdown windowModeDropdown = null!; - private Bindable scalingPositionX; - private Bindable scalingPositionY; - private Bindable scalingSizeX; - private Bindable scalingSizeY; + private Bindable scalingPositionX = null!; + private Bindable scalingPositionY = null!; + private Bindable scalingSizeX = null!; + private Bindable scalingSizeY = null!; private const int transition_duration = 400; [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host) { + window = host.Window; + scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); sizeFullscreen = config.GetBindable(FrameworkSetting.SizeFullscreen); scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); @@ -67,14 +70,15 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, Gam scalingPositionX = osuConfig.GetBindable(OsuSetting.ScalingPositionX); scalingPositionY = osuConfig.GetBindable(OsuSetting.ScalingPositionY); - if (host.Window != null) + if (window != null) { - currentDisplay.BindTo(host.Window.CurrentDisplayBindable); - windowModes.BindTo(host.Window.SupportedWindowModes); + currentDisplay.BindTo(window.CurrentDisplayBindable); + windowModes.BindTo(window.SupportedWindowModes); + window.DisplaysChanged += onDisplaysChanged; } - if (host.Window is WindowsWindow windowsWindow) - fullscreenCapability.BindTo(windowsWindow.FullscreenCapability); + if (host.Renderer is IWindowsRenderer windowsRenderer) + fullscreenCapability.BindTo(windowsRenderer.FullscreenCapability); Children = new Drawable[] { @@ -87,7 +91,7 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, Gam displayDropdown = new DisplaySettingsDropdown { LabelText = GraphicsSettingsStrings.Display, - Items = host.Window?.Displays, + Items = window?.Displays, Current = currentDisplay, }, resolutionDropdown = new ResolutionSettingsDropdown @@ -202,19 +206,6 @@ protected override void LoadComplete() // initial update bypasses transforms updateScalingModeVisibility(); - void updateDisplayModeDropdowns() - { - if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen) - resolutionDropdown.Show(); - else - resolutionDropdown.Hide(); - - if (displayDropdown.Items.Count() > 1) - displayDropdown.Show(); - else - displayDropdown.Hide(); - } - void updateScalingModeVisibility() { if (scalingMode.Value == ScalingMode.Off) @@ -225,6 +216,28 @@ void updateScalingModeVisibility() } } + private void onDisplaysChanged(IEnumerable displays) + { + Scheduler.AddOnce(d => + { + displayDropdown.Items = d; + updateDisplayModeDropdowns(); + }, displays); + } + + private void updateDisplayModeDropdowns() + { + if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen) + resolutionDropdown.Show(); + else + resolutionDropdown.Hide(); + + if (displayDropdown.Items.Count() > 1) + displayDropdown.Show(); + else + displayDropdown.Hide(); + } + private void updateScreenModeWarning() { if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS) @@ -280,7 +293,7 @@ private void bindPreviewEvent(Bindable bindable) }; } - private Drawable preview; + private Drawable? preview; private void showPreview() { @@ -291,6 +304,14 @@ private void showPreview() preview.Expire(); } + protected override void Dispose(bool isDisposing) + { + if (window != null) + window.DisplaysChanged -= onDisplaysChanged; + + base.Dispose(isDisposing); + } + private class ScalingPreview : ScalingContainer { public ScalingPreview() diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index 9ff47578e9e4..c91a6a48d4b9 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -327,6 +327,50 @@ protected override void OnMidiUp(MidiUpEvent e) finalise(); } + protected override bool OnTabletAuxiliaryButtonPress(TabletAuxiliaryButtonPressEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + finalise(); + + return true; + } + + protected override void OnTabletAuxiliaryButtonRelease(TabletAuxiliaryButtonReleaseEvent e) + { + if (!HasFocus) + { + base.OnTabletAuxiliaryButtonRelease(e); + return; + } + + finalise(); + } + + protected override bool OnTabletPenButtonPress(TabletPenButtonPressEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + finalise(); + + return true; + } + + protected override void OnTabletPenButtonRelease(TabletPenButtonReleaseEvent e) + { + if (!HasFocus) + { + base.OnTabletPenButtonRelease(e); + return; + } + + finalise(); + } + private void clear() { if (bindTarget == null) @@ -387,14 +431,8 @@ private void updateBindTarget() if (bindTarget != null) bindTarget.IsBinding = true; } - private void updateStoreFromButton(KeyButton button) - { - realm.Run(r => - { - var binding = r.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); - r.Write(() => binding.KeyCombinationString = button.KeyBinding.KeyCombinationString); - }); - } + private void updateStoreFromButton(KeyButton button) => + realm.WriteAsync(r => r.Find(button.KeyBinding.ID).KeyCombinationString = button.KeyBinding.KeyCombinationString); private void updateIsDefaultValue() { diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 544259ddf149..f1e216f538f6 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -72,7 +72,7 @@ public TabletSettings(ITabletHandler tabletHandler) } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, LocalisationManager localisation) { Children = new Drawable[] { @@ -110,11 +110,10 @@ private void load(OsuColour colours) if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) { t.NewLine(); - t.AddText("If your tablet is not detected, please read "); - t.AddLink("this FAQ", LinkAction.External, RuntimeInfo.OS == RuntimeInfo.Platform.Windows + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription(RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux"); - t.AddText(" for troubleshooting steps."); + : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); + t.AddLinks(formattedSource.Text, formattedSource.Links); } }), } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs index 453dbd2e189b..beae5a6aad7d 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class BeatmapSettings : SettingsSubsection { - protected override LocalisableString Header => "Beatmaps"; + protected override LocalisableString Header => CommonStrings.Beatmaps; private SettingsButton importBeatmapsButton = null!; private SettingsButton deleteBeatmapsButton = null!; diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs index 5a91213eb873..17fef37e4088 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class CollectionsSettings : SettingsSubsection { - protected override LocalisableString Header => "Collections"; + protected override LocalisableString Header => CommonStrings.Collections; private SettingsButton importCollectionsButton = null!; @@ -51,7 +51,7 @@ private void load(LegacyImportManager? legacyImportManager, IDialogOverlay? dial private void deleteAllCollections() { realm.Write(r => r.RemoveAll()); - notificationOverlay?.Post(new ProgressCompletionNotification { Text = "Deleted all collections!" }); + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.DeletedAllCollections }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index d565576d099f..158e1a8aa07f 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osuTK; @@ -71,14 +72,14 @@ protected override void LoadComplete() { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Migration in progress", + Text = MaintenanceSettingsStrings.MigrationInProgress, Font = OsuFont.Default.With(size: 40) }, new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "This could take a few minutes depending on the speed of your disk(s).", + Text = MaintenanceSettingsStrings.MigrationDescription, Font = OsuFont.Default.With(size: 30) }, new LoadingSpinner(true) @@ -89,7 +90,7 @@ protected override void LoadComplete() { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Please avoid interacting with the game!", + Text = MaintenanceSettingsStrings.ProhibitedInteractDuringMigration, Font = OsuFont.Default.With(size: 30) }, } @@ -111,7 +112,7 @@ protected override void LoadComplete() { notifications.Post(new SimpleNotification { - Text = "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up.", + Text = MaintenanceSettingsStrings.FailedCleanupNotification, Activated = () => { originalStorage.PresentExternally(); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs index 0d32e33d8766..5de33fdd55c8 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.IO; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Overlays.Settings.Sections.Maintenance @@ -35,7 +36,7 @@ public class MigrationSelectScreen : DirectorySelectScreen public override bool HideOverlaysOnEnter => true; - public override LocalisableString HeaderText => "Please select a new location"; + public override LocalisableString HeaderText => MaintenanceSettingsStrings.SelectNewLocation; protected override void OnSelection(DirectoryInfo directory) { @@ -51,9 +52,9 @@ protected override void OnSelection(DirectoryInfo directory) // Quick test for whether there's already an osu! install at the target path. if (fileInfos.Any(f => f.Name == OsuGameBase.CLIENT_DATABASE_FILENAME)) { - dialogOverlay.Push(new ConfirmDialog("The target directory already seems to have an osu! install. Use that data instead?", () => + dialogOverlay.Push(new ConfirmDialog(MaintenanceSettingsStrings.TargetDirectoryAlreadyInstalledOsu, () => { - dialogOverlay.Push(new ConfirmDialog("To complete this operation, osu! will close. Please open it again to use the new data location.", () => + dialogOverlay.Push(new ConfirmDialog(MaintenanceSettingsStrings.RestartAndReOpenRequiredForCompletion, () => { (storage as OsuStorage)?.ChangeDataPath(target.FullName); game.Exit(); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs index d35d3ff468d6..51f6e1bf6005 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class ModPresetSettings : SettingsSubsection { - protected override LocalisableString Header => "Mod presets"; + protected override LocalisableString Header => CommonStrings.ModPresets; [Resolved] private RealmAccess realm { get; set; } = null!; @@ -64,7 +64,7 @@ private void onAllModPresetsDeleted(Task deletionTask) deleteAllButton.Enabled.Value = true; if (deletionTask.IsCompletedSuccessfully) - notificationOverlay?.Post(new ProgressCompletionNotification { Text = "Deleted all mod presets!" }); + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.DeletedAllModPresets }); else if (deletionTask.IsFaulted) Logger.Error(deletionTask.Exception, "Failed to delete all mod presets"); } @@ -81,7 +81,7 @@ private void onModPresetsUndeleted(Task undeletionTask) undeleteButton.Enabled.Value = true; if (undeletionTask.IsCompletedSuccessfully) - notificationOverlay?.Post(new ProgressCompletionNotification { Text = "Restored all deleted mod presets!" }); + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.RestoredAllDeletedModPresets }); else if (undeletionTask.IsFaulted) Logger.Error(undeletionTask.Exception, "Failed to restore mod presets"); } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs index 70e11d45dc7b..eb2d3171ea36 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class ScoreSettings : SettingsSubsection { - protected override LocalisableString Header => "Scores"; + protected override LocalisableString Header => CommonStrings.Scores; private SettingsButton importScoresButton = null!; private SettingsButton deleteScoresButton = null!; diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs index c95b1d4dd855..93c65513b7a5 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class SkinSettings : SettingsSubsection { - protected override LocalisableString Header => "Skins"; + protected override LocalisableString Header => CommonStrings.Skins; private SettingsButton importSkinsButton = null!; private SettingsButton deleteSkinsButton = null!; diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index c6a4cbbcaa5f..2c4832c68ae6 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -16,6 +16,11 @@ public abstract class SidebarButton : OsuButton [Resolved] protected OverlayColourProvider ColourProvider { get; private set; } + protected SidebarButton() + : base(HoverSampleSet.ButtonSidebar) + { + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 548d75c9a975..6dd9e2a56dc5 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Extensions.EnumExtensions; @@ -23,25 +22,38 @@ namespace osu.Game.Overlays { public class SettingsToolboxGroup : Container, IExpandable { + private readonly string title; public const int CONTAINER_WIDTH = 270; private const float transition_duration = 250; - private const int border_thickness = 2; private const int header_height = 30; private const int corner_radius = 5; - private const float fade_duration = 800; - private const float inactive_alpha = 0.5f; - private readonly Cached headerTextVisibilityCache = new Cached(); - private readonly FillFlowContainer content; + protected override Container Content => content; + + private readonly FillFlowContainer content = new FillFlowContainer + { + Name = @"Content", + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Top = 5, Bottom = 10 }, + Spacing = new Vector2(0, 15), + }; public BindableBool Expanded { get; } = new BindableBool(true); - private readonly OsuSpriteText headerText; + private OsuSpriteText headerText = null!; - private readonly Container headerContent; + private Container headerContent = null!; + + private Box background = null!; + + private IconButton expandButton = null!; /// /// Create a new instance. @@ -49,20 +61,25 @@ public class SettingsToolboxGroup : Container, IExpandable /// The title to be displayed in the header of this group. public SettingsToolboxGroup(string title) { + this.title = title; + AutoSizeAxes = Axes.Y; Width = CONTAINER_WIDTH; Masking = true; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) + { CornerRadius = corner_radius; - BorderColour = Color4.Black; - BorderThickness = border_thickness; InternalChildren = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.5f, + Alpha = 0.1f, + Colour = colourProvider?.Background4 ?? Color4.Black, }, new FillFlowContainer { @@ -88,7 +105,7 @@ public SettingsToolboxGroup(string title) Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), Padding = new MarginPadding { Left = 10, Right = 30 }, }, - new IconButton + expandButton = new IconButton { Origin = Anchor.Centre, Anchor = Anchor.CentreRight, @@ -99,19 +116,7 @@ public SettingsToolboxGroup(string title) }, } }, - content = new FillFlowContainer - { - Name = @"Content", - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeDuration = transition_duration, - AutoSizeEasing = Easing.OutQuint, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(15), - Spacing = new Vector2(0, 15), - } + content } }, }; @@ -175,9 +180,10 @@ private void updateExpandedState(ValueChangedEvent expanded) private void updateFadeState() { - this.FadeTo(IsHovered ? 1 : inactive_alpha, fade_duration, Easing.OutQuint); - } + const float fade_duration = 500; - protected override Container Content => content; + background.FadeTo(IsHovered ? 1 : 0.1f, fade_duration, Easing.OutQuint); + expandButton.FadeTo(IsHovered ? 1 : 0, fade_duration, Easing.OutQuint); + } } } diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index f2b637c1041a..624be0b25c61 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -23,10 +23,14 @@ public bool OnPressed(KeyBindingPressEvent e) { case GlobalAction.DecreaseVolume: case GlobalAction.IncreaseVolume: + ActionRequested?.Invoke(e.Action); + return true; + case GlobalAction.ToggleMute: case GlobalAction.NextVolumeMeter: case GlobalAction.PreviousVolumeMeter: - ActionRequested?.Invoke(e.Action); + if (!e.Repeat) + ActionRequested?.Invoke(e.Action); return true; } diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs index 4f1083a75c57..15c455416cf1 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -4,6 +4,7 @@ #nullable disable using System.Linq; +using Markdig.Extensions.CustomContainers; using Markdig.Extensions.Yaml; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -16,6 +17,7 @@ namespace osu.Game.Overlays.Wiki.Markdown public class WikiMarkdownContainer : OsuMarkdownContainer { protected override bool Footnotes => true; + protected override bool CustomContainers => true; public string CurrentPath { @@ -26,6 +28,11 @@ protected override void AddMarkdownComponent(IMarkdownObject markdownObject, Fil { switch (markdownObject) { + case CustomContainer: + // infoboxes are parsed into CustomContainer objects, but we don't have support for infoboxes yet. + // todo: add support for infobox. + break; + case YamlFrontMatterBlock yamlFrontMatterBlock: container.Add(new WikiNoticeContainer(yamlFrontMatterBlock)); break; diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index f6abf259e8a0..f345504ca178 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -46,6 +46,10 @@ public class LegacyReplayFrame : ReplayFrame [IgnoreMember] public bool MouseRight2 => ButtonState.HasFlagFast(ReplayButtonState.Right2); + [JsonIgnore] + [IgnoreMember] + public bool Smoke => ButtonState.HasFlagFast(ReplayButtonState.Smoke); + [Key(3)] public ReplayButtonState ButtonState; diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs index 472621166641..ca7ca798133f 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs @@ -3,14 +3,21 @@ #nullable disable +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; 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.Localisation; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -18,6 +25,7 @@ using osu.Game.Overlays.OSD; using osu.Game.Overlays.Settings.Sections; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Components.TernaryButtons; namespace osu.Game.Rulesets.Edit { @@ -30,7 +38,7 @@ public abstract class DistancedHitObjectComposer : HitObjectComposer DistanceSpacingMultiplier { get; } = new BindableDouble(1.0) + public BindableDouble DistanceSpacingMultiplier { get; } = new BindableDouble(1.0) { MinValue = 0.1, MaxValue = 6.0, @@ -42,35 +50,114 @@ public abstract class DistancedHitObjectComposer : HitObjectComposer> distanceSpacingSlider; + private ExpandableButton currentDistanceSpacingButton; [Resolved(canBeNull: true)] private OnScreenDisplay onScreenDisplay { get; set; } + protected readonly Bindable DistanceSnapToggle = new Bindable(); + + private bool distanceSnapMomentary; + protected DistancedHitObjectComposer(Ruleset ruleset) : base(ruleset) { } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) + AddInternal(new Container { - Padding = new MarginPadding(10), - Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Child = new EditorToolboxGroup("snapping") + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] { - Child = distanceSpacingSlider = new ExpandableSlider> + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) { - Current = { BindTarget = DistanceSpacingMultiplier }, - KeyboardStep = adjust_step, + Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, + Child = new EditorToolboxGroup("snapping") + { + Children = new Drawable[] + { + distanceSpacingSlider = new ExpandableSlider> + { + KeyboardStep = adjust_step, + // Manual binding in LoadComplete to handle one-way event flow. + Current = DistanceSpacingMultiplier.GetUnboundCopy(), + }, + currentDistanceSpacingButton = new ExpandableButton + { + Action = () => + { + (HitObject before, HitObject after)? objects = getObjectsOnEitherSideOfCurrentTime(); + + Debug.Assert(objects != null); + + DistanceSpacingMultiplier.Value = ReadCurrentDistanceSnap(objects.Value.before, objects.Value.after); + DistanceSnapToggle.Value = TernaryState.True; + }, + RelativeSizeAxes = Axes.X, + } + } + } } } }); } + private (HitObject before, HitObject after)? getObjectsOnEitherSideOfCurrentTime() + { + HitObject lastBefore = Playfield.HitObjectContainer.AliveObjects.LastOrDefault(h => h.HitObject.StartTime <= EditorClock.CurrentTime)?.HitObject; + + if (lastBefore == null) + return null; + + HitObject firstAfter = Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime >= EditorClock.CurrentTime)?.HitObject; + + if (firstAfter == null) + return null; + + if (lastBefore == firstAfter) + return null; + + return (lastBefore, firstAfter); + } + + protected abstract double ReadCurrentDistanceSnap(HitObject before, HitObject after); + + protected override void Update() + { + base.Update(); + + (HitObject before, HitObject after)? objects = getObjectsOnEitherSideOfCurrentTime(); + + double currentSnap = objects == null + ? 0 + : ReadCurrentDistanceSnap(objects.Value.before, objects.Value.after); + + if (currentSnap > DistanceSpacingMultiplier.MinValue) + { + currentDistanceSpacingButton.Enabled.Value = currentDistanceSpacingButton.Expanded.Value + && !Precision.AlmostEquals(currentSnap, DistanceSpacingMultiplier.Value, DistanceSpacingMultiplier.Precision / 2); + currentDistanceSpacingButton.ContractedLabelText = $"current {currentSnap:N2}x"; + currentDistanceSpacingButton.ExpandedLabelText = $"Use current ({currentSnap:N2}x)"; + } + else + { + currentDistanceSpacingButton.Enabled.Value = false; + currentDistanceSpacingButton.ContractedLabelText = string.Empty; + currentDistanceSpacingButton.ExpandedLabelText = "Use current (unavailable)"; + } + } + protected override void LoadComplete() { base.LoadComplete(); @@ -88,22 +175,61 @@ protected override void LoadComplete() EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; }, true); + + // Manual binding to handle enabling distance spacing when the slider is interacted with. + distanceSpacingSlider.Current.BindValueChanged(spacing => + { + DistanceSpacingMultiplier.Value = spacing.NewValue; + DistanceSnapToggle.Value = TernaryState.True; + }); + DistanceSpacingMultiplier.BindValueChanged(spacing => distanceSpacingSlider.Current.Value = spacing.NewValue); + } + } + + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] + { + new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + }); + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) + return false; + + handleToggleViaKey(e); + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + handleToggleViaKey(e); + base.OnKeyUp(e); + } + + private void handleToggleViaKey(KeyboardEvent key) + { + bool altPressed = key.AltPressed; + + if (altPressed != distanceSnapMomentary) + { + distanceSnapMomentary = altPressed; + DistanceSnapToggle.Value = DistanceSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; } } - public bool OnPressed(KeyBindingPressEvent e) + public virtual bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { case GlobalAction.EditorIncreaseDistanceSpacing: case GlobalAction.EditorDecreaseDistanceSpacing: - return adjustDistanceSpacing(e.Action, adjust_step); + return AdjustDistanceSpacing(e.Action, adjust_step); } return false; } - public void OnReleased(KeyBindingReleaseEvent e) + public virtual void OnReleased(KeyBindingReleaseEvent e) { } @@ -113,13 +239,13 @@ public bool OnScroll(KeyBindingScrollEvent e) { case GlobalAction.EditorIncreaseDistanceSpacing: case GlobalAction.EditorDecreaseDistanceSpacing: - return adjustDistanceSpacing(e.Action, e.ScrollAmount * adjust_step); + return AdjustDistanceSpacing(e.Action, e.ScrollAmount * adjust_step); } return false; } - private bool adjustDistanceSpacing(GlobalAction action, float amount) + protected virtual bool AdjustDistanceSpacing(GlobalAction action, float amount) { if (DistanceSpacingMultiplier.Disabled) return false; @@ -129,12 +255,13 @@ private bool adjustDistanceSpacing(GlobalAction action, float amount) else if (action == GlobalAction.EditorDecreaseDistanceSpacing) DistanceSpacingMultiplier.Value -= amount; + DistanceSnapToggle.Value = TernaryState.True; return true; } public virtual float GetBeatSnapDistanceAt(HitObject referenceObject) { - return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * referenceObject.DifficultyControlPoint.SliderVelocity / BeatSnapProvider.BeatDivisor); + return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * 1 / BeatSnapProvider.BeatDivisor); } public virtual float DurationToDistance(HitObject referenceObject, double duration) diff --git a/osu.Game/Rulesets/Edit/ExpandableButton.cs b/osu.Game/Rulesets/Edit/ExpandableButton.cs new file mode 100644 index 000000000000..a66600bd58fd --- /dev/null +++ b/osu.Game/Rulesets/Edit/ExpandableButton.cs @@ -0,0 +1,101 @@ +// 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.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Rulesets.Edit +{ + internal class ExpandableButton : RoundedButton, IExpandable + { + private float actualHeight; + + public override float Height + { + get => base.Height; + set => base.Height = actualHeight = value; + } + + private LocalisableString contractedLabelText; + + /// + /// The label text to display when this button is in a contracted state. + /// + public LocalisableString ContractedLabelText + { + get => contractedLabelText; + set + { + if (value == contractedLabelText) + return; + + contractedLabelText = value; + + if (!Expanded.Value) + Text = value; + } + } + + private LocalisableString expandedLabelText; + + /// + /// The label text to display when this button is in an expanded state. + /// + public LocalisableString ExpandedLabelText + { + get => expandedLabelText; + set + { + if (value == expandedLabelText) + return; + + expandedLabelText = value; + + if (Expanded.Value) + Text = value; + } + } + + public BindableBool Expanded { get; } = new BindableBool(); + + [Resolved(canBeNull: true)] + private IExpandingContainer? expandingContainer { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + expandingContainer?.Expanded.BindValueChanged(containerExpanded => + { + Expanded.Value = containerExpanded.NewValue; + }, true); + + Expanded.BindValueChanged(expanded => + { + Text = expanded.NewValue ? expandedLabelText : contractedLabelText; + + if (expanded.NewValue) + { + SpriteText.Anchor = Anchor.Centre; + SpriteText.Origin = Anchor.Centre; + SpriteText.Font = OsuFont.GetFont(weight: FontWeight.Bold); + base.Height = actualHeight; + Background.Show(); + } + else + { + SpriteText.Anchor = Anchor.CentreLeft; + SpriteText.Origin = Anchor.CentreLeft; + SpriteText.Font = OsuFont.GetFont(weight: FontWeight.Regular); + base.Height = actualHeight / 2; + Background.Hide(); + } + }, true); + } + } +} diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index d3371d354358..26dd5dfa55d0 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -19,7 +19,8 @@ public ExpandingToolboxContainer(float contractedWidth, float expandedWidth) { RelativeSizeAxes = Axes.Y; - FillFlow.Spacing = new Vector2(10); + FillFlow.Spacing = new Vector2(5); + Padding = new MarginPadding { Vertical = 5 }; } protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 37c66037eb13..520fcb029046 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -12,10 +12,12 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -80,7 +82,7 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { Config = Dependencies.Get().GetConfigFor(Ruleset); @@ -102,7 +104,7 @@ private void load() InternalChildren = new Drawable[] { - new Container + PlayfieldContentContainer = new Container { Name = "Content", RelativeSizeAxes = Axes.Both, @@ -116,25 +118,37 @@ private void load() .WithChild(BlueprintContainer = CreateBlueprintContainer()) } }, - new ExpandingToolboxContainer(90, 200) + new Container { - Padding = new MarginPadding(10), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, Children = new Drawable[] { - new EditorToolboxGroup("toolbox (1-9)") + new Box { - Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, }, - new EditorToolboxGroup("toggles (Q~P)") + new ExpandingToolboxContainer(60, 200) { - Child = togglesCollection = new FillFlowContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - }, - } + new EditorToolboxGroup("toolbox (1-9)") + { + Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } + }, + new EditorToolboxGroup("toggles (Q~P)") + { + Child = togglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, + } + } + }, } }, }; @@ -152,6 +166,15 @@ private void load() EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } + /// + /// Houses all content relevant to the playfield. + /// + /// + /// Generally implementations should not be adding to this directly. + /// Use or instead. + /// + protected Container PlayfieldContentContainer { get; private set; } + protected override void LoadComplete() { base.LoadComplete(); @@ -215,7 +238,7 @@ protected virtual DrawableRuleset CreateDrawableRuleset(Ruleset ruleset protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed) + if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.ShiftPressed) return false; if (checkLeftToggleFromKey(e.Key, out int leftIndex)) diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 6d7706cde28f..d58a90115498 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +11,6 @@ using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Beatmaps.Timing; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.OpenGL.Vertices; @@ -20,6 +18,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -84,8 +83,6 @@ public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) flashlight.Combo.BindTo(Combo); drawableRuleset.KeyBindingInputManager.Add(flashlight); - - flashlight.Breaks = drawableRuleset.Beatmap.Breaks; } protected abstract Flashlight CreateFlashlight(); @@ -100,8 +97,6 @@ public abstract class Flashlight : Drawable public override bool RemoveCompletedTransforms => false; - public List Breaks = new List(); - private readonly float defaultFlashlightSize; private readonly float sizeMultiplier; private readonly bool comboBasedSize; @@ -119,46 +114,50 @@ private void load(ShaderManager shaderManager) shader = shaderManager.Load("PositionAndColour", FragmentShader); } + [Resolved] + private Player? player { get; set; } + + private readonly IBindable isBreakTime = new BindableBool(); + protected override void LoadComplete() { base.LoadComplete(); - Combo.ValueChanged += OnComboChange; + Combo.ValueChanged += _ => UpdateFlashlightSize(GetSize()); - using (BeginAbsoluteSequence(0)) + if (player != null) { - foreach (var breakPeriod in Breaks) - { - if (!breakPeriod.HasEffect) - continue; - - if (breakPeriod.Duration < FLASHLIGHT_FADE_DURATION * 2) continue; - - this.Delay(breakPeriod.StartTime + FLASHLIGHT_FADE_DURATION).FadeOutFromOne(FLASHLIGHT_FADE_DURATION); - this.Delay(breakPeriod.EndTime - FLASHLIGHT_FADE_DURATION).FadeInFromZero(FLASHLIGHT_FADE_DURATION); - } + isBreakTime.BindTo(player.IsBreakTime); + isBreakTime.BindValueChanged(_ => UpdateFlashlightSize(GetSize()), true); } } - protected abstract void OnComboChange(ValueChangedEvent e); + protected abstract void UpdateFlashlightSize(float size); protected abstract string FragmentShader { get; } - protected float GetSizeFor(int combo) + protected float GetSize() { float size = defaultFlashlightSize * sizeMultiplier; - if (comboBasedSize) - { - if (combo >= 200) - size *= 0.8f; - else if (combo >= 100) - size *= 0.9f; - } + if (isBreakTime.Value) + size *= 2.5f; + else if (comboBasedSize) + size *= GetComboScaleFor(Combo.Value); return size; } + protected virtual float GetComboScaleFor(int combo) + { + if (combo >= 200) + return 0.625f; + if (combo >= 100) + return 0.8125f; + + return 1.0f; + } + private Vector2 flashlightPosition; protected Vector2 FlashlightPosition @@ -201,6 +200,20 @@ public float FlashlightDim } } + private float flashlightSmoothness = 1.1f; + + public float FlashlightSmoothness + { + get => flashlightSmoothness; + set + { + if (flashlightSmoothness == value) return; + + flashlightSmoothness = value; + Invalidate(Invalidation.DrawNode); + } + } + private class FlashlightDrawNode : DrawNode { protected new Flashlight Source => (Flashlight)base.Source; @@ -210,6 +223,7 @@ private class FlashlightDrawNode : DrawNode private Vector2 flashlightPosition; private Vector2 flashlightSize; private float flashlightDim; + private float flashlightSmoothness; private IVertexBatch? quadBatch; private Action? addAction; @@ -228,6 +242,7 @@ public override void ApplyState() flashlightPosition = Vector2Extensions.Transform(Source.FlashlightPosition, DrawInfo.Matrix); flashlightSize = Source.FlashlightSize * DrawInfo.Matrix.ExtractScale().Xy; flashlightDim = Source.FlashlightDim; + flashlightSmoothness = Source.flashlightSmoothness; } public override void Draw(IRenderer renderer) @@ -249,6 +264,7 @@ public override void Draw(IRenderer renderer) shader.GetUniform("flashlightPos").UpdateValue(ref flashlightPosition); shader.GetUniform("flashlightSize").UpdateValue(ref flashlightSize); shader.GetUniform("flashlightDim").UpdateValue(ref flashlightDim); + shader.GetUniform("flashlightSmoothness").UpdateValue(ref flashlightSmoothness); renderer.DrawQuad(renderer.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour, vertexAction: addAction); diff --git a/osu.Game/Rulesets/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs index dec81d9bbd54..c2709db74795 100644 --- a/osu.Game/Rulesets/Objects/BarLineGenerator.cs +++ b/osu.Game/Rulesets/Objects/BarLineGenerator.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -40,14 +38,21 @@ public BarLineGenerator(IBeatmap beatmap) for (int i = 0; i < timingPoints.Count; i++) { TimingControlPoint currentTimingPoint = timingPoints[i]; + EffectControlPoint currentEffectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTimingPoint.Time); int currentBeat = 0; - // Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object - double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - currentTimingPoint.BeatLength : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; + // Stop on the next timing point, or if there is no next timing point stop slightly past the last object + double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; + double startTime = currentTimingPoint.Time; double barLength = currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; - for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++) + if (currentEffectPoint.OmitFirstBarLine) + { + startTime += barLength; + } + + for (double t = startTime; Precision.AlmostBigger(endTime, t); t += barLength, currentBeat++) { double roundedTime = Math.Round(t, MidpointRounding.AwayFromZero); diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index d20e0616e5d2..0f79e58201bd 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; @@ -198,6 +199,21 @@ protected virtual void CreateNestedHitObjects(CancellationToken cancellationToke /// [NotNull] protected virtual HitWindows CreateHitWindows() => new HitWindows(); + + public IList CreateSlidingSamples() + { + var slidingSamples = new List(); + + var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); + if (normalSample != null) + slidingSamples.Add(normalSample.With("sliderslide")); + + var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); + if (whistleSample != null) + slidingSamples.Add(whistleSample.With("sliderwhistle")); + + return slidingSamples; + } } public static class HitObjectExtensions diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index b289299a63dd..930ee0448ff9 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -199,8 +199,8 @@ private void readCustomSampleBanks(string str, SampleBankInfo bankInfo) if (stringAddBank == @"none") stringAddBank = null; - bankInfo.Normal = stringBank; - bankInfo.Add = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; + bankInfo.BankForNormal = stringBank; + bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; if (split.Length > 2) bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]); @@ -447,32 +447,54 @@ private List convertSoundType(LegacyHitSoundType type, SampleBank var soundTypes = new List { - new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.Normal, bankInfo.Volume, bankInfo.CustomSampleBank, + new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, bankInfo.CustomSampleBank, // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal)) }; if (type.HasFlagFast(LegacyHitSoundType.Finish)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); if (type.HasFlagFast(LegacyHitSoundType.Whistle)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); if (type.HasFlagFast(LegacyHitSoundType.Clap)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); return soundTypes; } private class SampleBankInfo { + /// + /// An optional overriding filename which causes all bank/sample specifications to be ignored. + /// public string Filename; - public string Normal; - public string Add; + /// + /// The bank identifier to use for the base ("hitnormal") sample. + /// Transferred to when appropriate. + /// + public string BankForNormal; + + /// + /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). + /// Transferred to when appropriate. + /// + public string BankForAdditions; + + /// + /// Hit sample volume (0-100). + /// See . + /// public int Volume; + /// + /// The index of the custom sample bank. Is only used if 2 or above for "reasons". + /// This will add a suffix to lookups, allowing extended bank lookups (ie. "normal-hitnormal-2"). + /// See . + /// public int CustomSampleBank; public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); @@ -503,7 +525,8 @@ public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => With(newName, newBank, newVolume); - public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = default, + public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + Optional newCustomSampleBank = default, Optional newIsLayered = default) => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); @@ -537,7 +560,8 @@ public FileHitSampleInfo(string filename, int volume) Path.ChangeExtension(Filename, null) }; - public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = default, + public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + Optional newCustomSampleBank = default, Optional newIsLayered = default) => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); diff --git a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs index 1e80bd165bd6..279de2f94026 100644 --- a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs +++ b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs @@ -11,12 +11,12 @@ namespace osu.Game.Rulesets.Timing /// /// A control point which adds an aggregated multiplier based on the provided 's BeatLength and 's SpeedMultiplier. /// - public class MultiplierControlPoint : IComparable + public class MultiplierControlPoint : IComparable, IControlPoint { /// /// The time in milliseconds at which this starts. /// - public double StartTime; + public double Time { get; set; } /// /// The aggregate multiplier which this provides. @@ -54,13 +54,13 @@ public MultiplierControlPoint() /// /// Creates a . /// - /// The start time of this . - public MultiplierControlPoint(double startTime) + /// The start time of this . + public MultiplierControlPoint(double time) { - StartTime = startTime; + Time = time; } // ReSharper disable once ImpureMethodCallOnReadonlyValueField - public int CompareTo(MultiplierControlPoint other) => StartTime.CompareTo(other?.StartTime); + public int CompareTo(MultiplierControlPoint other) => Time.CompareTo(other?.Time); } } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 73acb1759fe7..dd3a9502647d 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -384,7 +384,7 @@ private void applyRulesetMods(IReadOnlyList mods, OsuConfigManager config) // only show the cursor when within the playfield, by default. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Playfield.ReceivePositionalInputAt(screenSpacePos); - CursorContainer IProvideCursor.MenuCursor => Playfield.Cursor; + CursorContainer IProvideCursor.Cursor => Playfield.Cursor; public override GameplayCursorContainer Cursor => Playfield.Cursor; @@ -499,6 +499,7 @@ internal DrawableRuleset(Ruleset ruleset) /// /// The cursor being displayed by the . May be null if no cursor is provided. /// + [CanBeNull] public abstract GameplayCursorContainer Cursor { get; } /// diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 2ec72d8fe3b2..e59e45722a9b 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -202,16 +202,14 @@ protected virtual void OnNewDrawableHitObject(DrawableHitObject drawableHitObjec /// /// The cursor currently being used by this . May be null if no cursor is provided. /// + [CanBeNull] public GameplayCursorContainer Cursor { get; private set; } /// /// Provide a cursor which is to be used for gameplay. /// - /// - /// The default provided cursor is invisible when inside the bounds of the . - /// /// The cursor, or null to show the menu cursor. - protected virtual GameplayCursorContainer CreateCursor() => new InvisibleCursorContainer(); + protected virtual GameplayCursorContainer CreateCursor() => null; /// /// Registers a as a nested . @@ -522,14 +520,5 @@ internal double FutureLifetimeExtension } #endregion - - public class InvisibleCursorContainer : GameplayCursorContainer - { - protected override Drawable CreateCursor() => new InvisibleCursor(); - - private class InvisibleCursor : Drawable - { - } - } } } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 1a97153f2f91..64ac021204cd 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -230,9 +230,9 @@ public RulesetKeyBindingContainer(RulesetInfo ruleset, int variant, Simultaneous { } - protected override void ReloadMappings() + protected override void ReloadMappings(IQueryable realmKeyBindings) { - base.ReloadMappings(); + base.ReloadMappings(realmKeyBindings); KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs index 0bd8aa64c9d2..c957a84eb124 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs @@ -20,7 +20,7 @@ public float GetLength(double startTime, double endTime, double timeRange, float return -PositionAt(startTime, endTime, timeRange, scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) => (float)((time - currentTime) / timeRange * scrollLength); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs index d2fb9e353135..f78509f9192b 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs @@ -53,8 +53,9 @@ public interface IScrollAlgorithm /// The current time. /// The amount of visible time. /// The absolute spatial length through . + /// The time to be used for control point lookups (ie. the parent's start time for nested hit objects). /// The absolute spatial position. - float PositionAt(double time, double currentTime, double timeRange, float scrollLength); + float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null); /// /// Computes the time which brings a point to a provided spatial position given the current time. @@ -63,7 +64,7 @@ public interface IScrollAlgorithm /// The current time. /// The amount of visible time. /// The absolute spatial length through . - /// The time at which == . + /// The time at which == . double TimeAt(float position, double currentTime, double timeRange, float scrollLength); /// diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index d41117bce859..54079c7895e0 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs @@ -4,22 +4,20 @@ #nullable disable using System; +using System.Linq; using osu.Framework.Lists; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Timing; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class OverlappingScrollAlgorithm : IScrollAlgorithm { - private readonly MultiplierControlPoint searchPoint; - private readonly SortedList controlPoints; public OverlappingScrollAlgorithm(SortedList controlPoints) { this.controlPoints = controlPoints; - - searchPoint = new MultiplierControlPoint(); } public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) @@ -37,8 +35,8 @@ public float GetLength(double startTime, double endTime, double timeRange, float return -PositionAt(startTime, endTime, timeRange, scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => (float)((time - currentTime) / timeRange * controlPointAt(time).Multiplier * scrollLength); + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) + => (float)((time - currentTime) / timeRange * controlPointAt(originTime ?? time).Multiplier * scrollLength); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { @@ -52,7 +50,7 @@ public double TimeAt(float position, double currentTime, double timeRange, float for (; i < controlPoints.Count; i++) { float lastPos = pos; - pos = PositionAt(controlPoints[i].StartTime, currentTime, timeRange, scrollLength); + pos = PositionAt(controlPoints[i].Time, currentTime, timeRange, scrollLength); if (pos > position) { @@ -64,7 +62,7 @@ public double TimeAt(float position, double currentTime, double timeRange, float i = Math.Clamp(i, 0, controlPoints.Count - 1); - return controlPoints[i].StartTime + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; + return controlPoints[i].Time + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; } public void Reset() @@ -78,19 +76,11 @@ public void Reset() /// The . private MultiplierControlPoint controlPointAt(double time) { - if (controlPoints.Count == 0) - return new MultiplierControlPoint(double.NegativeInfinity); - - if (time < controlPoints[0].StartTime) - return controlPoints[0]; - - searchPoint.StartTime = time; - int index = controlPoints.BinarySearch(searchPoint); - - if (index < 0) - index = ~index - 1; - - return controlPoints[index]; + return ControlPointInfo.BinarySearch(controlPoints, time) + // The standard binary search will fail if there's no control points, or if the time is before the first. + // For this method, we want to use the first control point in the latter case. + ?? controlPoints.FirstOrDefault() + ?? new MultiplierControlPoint(double.NegativeInfinity); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs index bfddc2257334..774beb20c791 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs @@ -38,7 +38,7 @@ public float GetLength(double startTime, double endTime, double timeRange, float return (float)(objectLength * scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) { double timelineLength = relativePositionAt(time, timeRange) - relativePositionAt(currentTime, timeRange); return (float)(timelineLength * scrollLength); @@ -121,7 +121,7 @@ private void generatePositionMappings(in double timeRange) if (controlPoints.Count == 0) return; - positionMappings.Add(new PositionMapping(controlPoints[0].StartTime, controlPoints[0])); + positionMappings.Add(new PositionMapping(controlPoints[0].Time, controlPoints[0])); for (int i = 0; i < controlPoints.Count - 1; i++) { @@ -129,9 +129,9 @@ private void generatePositionMappings(in double timeRange) var next = controlPoints[i + 1]; // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier - float length = (float)((next.StartTime - current.StartTime) / timeRange * current.Multiplier); + float length = (float)((next.Time - current.Time) / timeRange * current.Multiplier); - positionMappings.Add(new PositionMapping(next.StartTime, next, positionMappings[^1].Position + length)); + positionMappings.Add(new PositionMapping(next.Time, next, positionMappings[^1].Position + length)); } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 825aba5bc2af..68469d083c86 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -158,9 +158,9 @@ private void load() // Trim unwanted sequences of timing changes timingChanges = timingChanges // Collapse sections after the last hit object - .Where(s => s.StartTime <= lastObjectTime) + .Where(s => s.Time <= lastObjectTime) // Collapse sections with the same start time - .GroupBy(s => s.StartTime).Select(g => g.Last()).OrderBy(s => s.StartTime); + .GroupBy(s => s.Time).Select(g => g.Last()).OrderBy(s => s.Time); ControlPoints.AddRange(timingChanges); diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index d0aca4e7fce6..424fc7c44c7e 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -5,10 +5,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; using osu.Framework.Layout; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -93,9 +93,9 @@ public double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) /// /// Given a time, return the position along the scrolling axis within this at time . /// - public float PositionAtTime(double time, double currentTime) + public float PositionAtTime(double time, double currentTime, double? originTime = null) { - float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength); + float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength, originTime); return axisInverted ? -scrollPosition : scrollPosition; } @@ -127,6 +127,16 @@ public float LengthAtTime(double startTime, double endTime) private float scrollLength => scrollingAxis == Direction.Horizontal ? DrawWidth : DrawHeight; + public override void Add(HitObjectLifetimeEntry entry) + { + // Scroll info is not available until loaded. + // The lifetime of all entries will be updated in the first Update. + if (IsLoaded) + setComputedLifetimeStart(entry); + + base.Add(entry); + } + protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) { base.AddDrawable(entry, drawable); @@ -145,7 +155,6 @@ protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHit private void invalidateHitObject(DrawableHitObject hitObject) { - hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); layoutComputed.Remove(hitObject); } @@ -157,10 +166,8 @@ protected override void Update() layoutComputed.Clear(); - // Reset lifetime to the conservative estimation. - // If a drawable becomes alive by this lifetime, its lifetime will be updated to a more precise lifetime in the next update. foreach (var entry in Entries) - entry.SetInitialLifetime(); + setComputedLifetimeStart(entry); scrollingInfo.Algorithm.Reset(); @@ -187,42 +194,52 @@ protected override void UpdateAfterChildrenLife() } } - private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) - { - // Origin position may be relative to the parent size - Debug.Assert(hitObject.Parent != null); + /// + /// Get a conservative maximum bounding box of a corresponding to . + /// It is used to calculate when the hit object appears. + /// + protected virtual RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) => new RectangleF().Inflate(100); - float originAdjustment = 0.0f; + private double computeDisplayStartTime(HitObjectLifetimeEntry entry) + { + RectangleF boundingBox = GetConservativeBoundingBox(entry); + float startOffset = 0; - // calculate the dimension of the part of the hitobject that should already be visible - // when the hitobject origin first appears inside the scrolling container switch (direction.Value) { - case ScrollingDirection.Up: - originAdjustment = hitObject.OriginPosition.Y; + case ScrollingDirection.Right: + startOffset = boundingBox.Right; break; case ScrollingDirection.Down: - originAdjustment = hitObject.DrawHeight - hitObject.OriginPosition.Y; + startOffset = boundingBox.Bottom; break; case ScrollingDirection.Left: - originAdjustment = hitObject.OriginPosition.X; + startOffset = -boundingBox.Left; break; - case ScrollingDirection.Right: - originAdjustment = hitObject.DrawWidth - hitObject.OriginPosition.X; + case ScrollingDirection.Up: + startOffset = -boundingBox.Top; break; } - double computedStartTime = scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); + return scrollingInfo.Algorithm.GetDisplayStartTime(entry.HitObject.StartTime, startOffset, timeRange.Value, scrollLength); + } + + private void setComputedLifetimeStart(HitObjectLifetimeEntry entry) + { + double computedStartTime = computeDisplayStartTime(entry); // always load the hitobject before its first judgement offset - return Math.Min(hitObject.HitObject.StartTime - hitObject.MaximumJudgementOffset, computedStartTime); + double judgementOffset = entry.HitObject.HitWindows?.WindowFor(Scoring.HitResult.Miss) ?? 0; + entry.LifetimeStart = Math.Min(entry.HitObject.StartTime - judgementOffset, computedStartTime); } - private void updateLayoutRecursive(DrawableHitObject hitObject) + private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null) { + parentHitObjectStartTime ??= hitObject.HitObject.StartTime; + if (hitObject.HitObject is IHasDuration e) { float length = LengthAtTime(hitObject.HitObject.StartTime, e.EndTime); @@ -234,16 +251,17 @@ private void updateLayoutRecursive(DrawableHitObject hitObject) foreach (var obj in hitObject.NestedHitObjects) { - updateLayoutRecursive(obj); + updateLayoutRecursive(obj, parentHitObjectStartTime); - // Nested hitobjects don't need to scroll, but they do need accurate positions - updatePosition(obj, hitObject.HitObject.StartTime); + // Nested hitobjects don't need to scroll, but they do need accurate positions and start lifetime + updatePosition(obj, hitObject.HitObject.StartTime, parentHitObjectStartTime); + setComputedLifetimeStart(obj.Entry); } } - private void updatePosition(DrawableHitObject hitObject, double currentTime) + private void updatePosition(DrawableHitObject hitObject, double currentTime, double? parentHitObjectStartTime = null) { - float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime); + float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime, parentHitObjectStartTime); if (scrollingAxis == Direction.Horizontal) hitObject.X = position; diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index 078f06b7459c..34e5b7f9de27 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -38,6 +38,8 @@ private void load() /// public virtual Vector2 ScreenSpacePositionAtTime(double time) => HitObjectContainer.ScreenSpacePositionAtTime(time); - protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); + protected sealed override HitObjectContainer CreateHitObjectContainer() => CreateScrollingHitObjectContainer(); + + protected virtual ScrollingHitObjectContainer CreateScrollingHitObjectContainer() => new ScrollingHitObjectContainer(); } } diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index a7faf961cf7f..aa8e202e22fc 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -25,6 +25,26 @@ public BindableBeatDivisor(int value = 1) BindValueChanged(_ => ensureValidDivisor()); } + /// + /// Set a divisor, updating the valid divisor range appropriately. + /// + /// The intended divisor. + public void SetArbitraryDivisor(int divisor) + { + // If the current valid divisor range doesn't contain the proposed value, attempt to find one which does. + if (!ValidDivisors.Value.Presets.Contains(divisor)) + { + if (BeatDivisorPresetCollection.COMMON.Presets.Contains(divisor)) + ValidDivisors.Value = BeatDivisorPresetCollection.COMMON; + else if (BeatDivisorPresetCollection.TRIPLETS.Presets.Contains(divisor)) + ValidDivisors.Value = BeatDivisorPresetCollection.TRIPLETS; + else + ValidDivisors.Value = BeatDivisorPresetCollection.Custom(divisor); + } + + Value = divisor; + } + private void updateBindableProperties() { ensureValidDivisor(); diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs index 82e94dc862a3..071bb9fdcbac 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs @@ -8,13 +8,12 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -30,9 +29,9 @@ public class EditorRadioButton : OsuButton, IHasTooltip public readonly RadioButton Button; private Color4 defaultBackgroundColour; - private Color4 defaultBubbleColour; + private Color4 defaultIconColour; private Color4 selectedBackgroundColour; - private Color4 selectedBubbleColour; + private Color4 selectedIconColour; private Drawable icon; @@ -50,20 +49,13 @@ public EditorRadioButton(RadioButton button) } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - defaultBackgroundColour = colours.Gray3; - defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); - selectedBackgroundColour = colours.BlueDark; - selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { @@ -98,7 +90,7 @@ private void updateSelectionState() return; BackgroundColour = Button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - icon.Colour = Button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; + icon.Colour = Button.Selected.Value ? selectedIconColour : defaultIconColour; } protected override SpriteText CreateText() => new OsuSpriteText diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 55302833c189..1fb5c0285d2d 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -6,12 +6,11 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -20,9 +19,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons internal class DrawableTernaryButton : OsuButton { private Color4 defaultBackgroundColour; - private Color4 defaultBubbleColour; + private Color4 defaultIconColour; private Color4 selectedBackgroundColour; - private Color4 selectedBubbleColour; + private Color4 selectedIconColour; private Drawable icon; @@ -38,20 +37,13 @@ public DrawableTernaryButton(TernaryButton button) } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - defaultBackgroundColour = colours.Gray3; - defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); - selectedBackgroundColour = colours.BlueDark; - selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { @@ -85,17 +77,17 @@ private void updateSelectionState() switch (Button.Bindable.Value) { case TernaryState.Indeterminate: - icon.Colour = selectedBubbleColour.Darken(0.5f); + icon.Colour = selectedIconColour.Darken(0.5f); BackgroundColour = selectedBackgroundColour.Darken(0.5f); break; case TernaryState.False: - icon.Colour = defaultBubbleColour; + icon.Colour = defaultIconColour; BackgroundColour = defaultBackgroundColour; break; case TernaryState.True: - icon.Colour = selectedBubbleColour; + icon.Colour = selectedIconColour; BackgroundColour = selectedBackgroundColour; break; } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 40403e08ad80..6dca79954919 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -123,16 +123,6 @@ private void load(OverlayColourProvider colourProvider) } }, new Drawable[] - { - new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - Padding = new MarginPadding { Horizontal = 15 }, - Text = "beat snap", - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre - }, - }, - new Drawable[] { new Container { @@ -173,6 +163,16 @@ private void load(OverlayColourProvider colourProvider) } } }, + new Drawable[] + { + new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) + { + Padding = new MarginPadding { Horizontal = 15, Vertical = 8 }, + Text = "beat snap", + RelativeSizeAxes = Axes.X, + TextAnchor = Anchor.TopCentre, + }, + }, }, RowDimensions = new[] { @@ -209,6 +209,17 @@ private void cycleDivisorType(int direction) } } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ShiftPressed && e.Key >= Key.Number1 && e.Key <= Key.Number9) + { + beatDivisor.SetArbitraryDivisor(e.Key - Key.Number0); + return true; + } + + return base.OnKeyDown(e); + } + internal class DivisorDisplay : OsuAnimatedButton, IHasPopover { public BindableBeatDivisor BeatDivisor { get; } = new BindableBeatDivisor(); @@ -306,17 +317,7 @@ private void setPresets() return; } - if (!BeatDivisor.ValidDivisors.Value.Presets.Contains(divisor)) - { - if (BeatDivisorPresetCollection.COMMON.Presets.Contains(divisor)) - BeatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.COMMON; - else if (BeatDivisorPresetCollection.TRIPLETS.Presets.Contains(divisor)) - BeatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.TRIPLETS; - else - BeatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.Custom(divisor); - } - - BeatDivisor.Value = divisor; + BeatDivisor.SetArbitraryDivisor(divisor); this.HidePopover(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 8b38d9c612a9..43ad270c16f4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; @@ -13,7 +12,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -61,25 +59,31 @@ private void load() { case NotifyCollectionChangedAction.Add: foreach (object o in args.NewItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); + { + if (blueprintMap.TryGetValue((T)o, out var blueprint)) + blueprint.Select(); + } break; case NotifyCollectionChangedAction.Remove: foreach (object o in args.OldItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); + { + if (blueprintMap.TryGetValue((T)o, out var blueprint)) + blueprint.Deselect(); + } break; } }; SelectionHandler = CreateSelectionHandler(); - SelectionHandler.DeselectAll = deselectAll; + SelectionHandler.DeselectAll = DeselectAll; SelectionHandler.SelectedItems.BindTo(SelectedItems); AddRangeInternal(new[] { - DragBox = CreateDragBox(selectBlueprintsFromDragRectangle), + DragBox = CreateDragBox(), SelectionHandler, SelectionBlueprints = CreateSelectionBlueprintContainer(), SelectionHandler.CreateProxy(), @@ -101,12 +105,7 @@ private void load() [CanBeNull] protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; - protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); - - /// - /// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to. - /// - protected virtual bool AllowDeselectionDuringDrag => true; + protected virtual DragBox CreateDragBox() => new DragBox(); protected override bool OnMouseDown(MouseDownEvent e) { @@ -142,7 +141,7 @@ protected override bool OnClick(ClickEvent e) if (endClickSelection(e) || ClickedBlueprint != null) return true; - deselectAll(); + DeselectAll(); return true; } @@ -171,11 +170,15 @@ protected override void OnMouseUp(MouseUpEvent e) finishSelectionMovement(); } + private MouseButtonEvent lastDragEvent; + protected override bool OnDragStart(DragStartEvent e) { if (e.Button == MouseButton.Right) return false; + lastDragEvent = e; + if (movementBlueprints != null) { isDraggingBlueprint = true; @@ -183,30 +186,21 @@ protected override bool OnDragStart(DragStartEvent e) return true; } - if (DragBox.HandleDrag(e)) - { - DragBox.Show(); - return true; - } - - return false; + DragBox.HandleDrag(e); + DragBox.Show(); + return true; } protected override void OnDrag(DragEvent e) { - if (e.Button == MouseButton.Right) - return; - - if (DragBox.State == Visibility.Visible) - DragBox.HandleDrag(e); + lastDragEvent = e; moveCurrentSelection(e); } protected override void OnDragEnd(DragEndEvent e) { - if (e.Button == MouseButton.Right) - return; + lastDragEvent = null; if (isDraggingBlueprint) { @@ -214,8 +208,19 @@ protected override void OnDragEnd(DragEndEvent e) changeHandler?.EndChange(); } - if (DragBox.State == Visibility.Visible) - DragBox.Hide(); + DragBox.Hide(); + } + + protected override void Update() + { + base.Update(); + + if (lastDragEvent != null && DragBox.State == Visibility.Visible) + { + lastDragEvent.Target = this; + DragBox.HandleDrag(lastDragEvent); + UpdateSelectionFromDragBox(); + } } /// @@ -233,7 +238,7 @@ protected override bool OnKeyDown(KeyDownEvent e) if (!SelectionHandler.SelectedBlueprints.Any()) return false; - deselectAll(); + DeselectAll(); return true; } @@ -380,44 +385,39 @@ private bool endClickSelection(MouseButtonEvent e) } /// - /// Select all masks in a given rectangle selection area. + /// Select all blueprints in a selection area specified by . /// - /// The rectangle to perform a selection on in screen-space coordinates. - private void selectBlueprintsFromDragRectangle(RectangleF rect) + protected virtual void UpdateSelectionFromDragBox() { + var quad = DragBox.Box.ScreenSpaceDrawQuad; + foreach (var blueprint in SelectionBlueprints) { - // only run when utmost necessary to avoid unnecessary rect computations. - bool isValidForSelection() => blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint); - switch (blueprint.State) { - case SelectionState.NotSelected: - if (isValidForSelection()) - blueprint.Select(); - break; - case SelectionState.Selected: - if (AllowDeselectionDuringDrag && !isValidForSelection()) + // Selection is preserved even after blueprint becomes dead. + if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint)) blueprint.Deselect(); break; + + case SelectionState.NotSelected: + if (blueprint.IsAlive && blueprint.IsPresent && quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + blueprint.Select(); + break; } } } /// - /// Selects all s. + /// Select all currently-present items. /// - protected virtual void SelectAll() - { - // Scheduled to allow the change in lifetime to take place. - Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select())); - } + protected abstract void SelectAll(); /// - /// Deselects all selected s. + /// Deselect all selected items. /// - private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); + protected void DeselectAll() => SelectedItems.Clear(); protected virtual void OnBlueprintSelected(SelectionBlueprint blueprint) { diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index 98079116cd11..6e54e98740b3 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -53,9 +53,16 @@ protected override void CreateContent() float maxDistance = new Vector2(dx, dy).Length; int requiredCircles = Math.Min(MaxIntervals, (int)(maxDistance / DistanceBetweenTicks)); + // We need to offset the drawn lines to the next valid snap for the currently selected divisor. + // + // Picture the scenario where the user has just placed an object on a 1/2 snap, then changes to + // 1/3 snap and expects to be able to place the next object on a valid 1/3 snap, regardless of the + // fact that the 1/2 snap reference object is not valid for 1/3 snapping. + float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0); + for (int i = 0; i < requiredCircles; i++) { - float diameter = (i + 1) * DistanceBetweenTicks * 2; + float diameter = (offset + (i + 1) * DistanceBetweenTicks) * 2; AddInternal(new Ring(ReferenceObject, GetColourForIndexFromPlacement(i)) { diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 4c37d200bcc3..ec07da43a092 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics.UserInterface; @@ -37,7 +36,6 @@ public class ComposeBlueprintContainer : EditorBlueprintContainer protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler; private PlacementBlueprint currentPlacement; - private InputManager inputManager; /// /// Positional input must be received outside the container's bounds, @@ -66,8 +64,6 @@ protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); - Beatmap.HitObjectAdded += hitObjectAdded; // updates to selected are handled for us by SelectionHandler. @@ -220,7 +216,7 @@ private void refreshTool() private void updatePlacementPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position); // if no time was found from positional snapping, we should still quantize to the beat. snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); diff --git a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs index 838562719d95..905d47533ad5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs @@ -8,7 +8,6 @@ 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.Framework.Input.Events; using osu.Framework.Layout; @@ -21,18 +20,13 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public class DragBox : CompositeDrawable, IStateful { - protected readonly Action PerformSelection; - - protected Drawable Box; + public Drawable Box { get; private set; } /// /// Creates a new . /// - /// A delegate that performs drag selection. - public DragBox(Action performSelection) + public DragBox() { - PerformSelection = performSelection; - RelativeSizeAxes = Axes.Both; AlwaysPresent = true; Alpha = 0; @@ -46,30 +40,14 @@ private void load() protected virtual Drawable CreateBox() => new BoxWithBorders(); - private RectangleF? dragRectangle; - /// /// Handle a forwarded mouse event. /// /// The mouse event. - /// Whether the event should be handled and blocking. - public virtual bool HandleDrag(MouseButtonEvent e) + public virtual void HandleDrag(MouseButtonEvent e) { - var dragPosition = e.ScreenSpaceMousePosition; - var dragStartPosition = e.ScreenSpaceMouseDownPosition; - - var dragQuad = new Quad(dragStartPosition.X, dragStartPosition.Y, dragPosition.X - dragStartPosition.X, dragPosition.Y - dragStartPosition.Y); - - // We use AABBFloat instead of RectangleF since it handles negative sizes for us - var rec = dragQuad.AABBFloat; - dragRectangle = rec; - - var topLeft = ToLocalSpace(rec.TopLeft); - var bottomRight = ToLocalSpace(rec.BottomRight); - - Box.Position = topLeft; - Box.Size = bottomRight - topLeft; - return true; + Box.Position = Vector2.ComponentMin(e.MouseDownPosition, e.MousePosition); + Box.Size = Vector2.ComponentMax(e.MouseDownPosition, e.MousePosition) - Box.Position; } private Visibility state; @@ -87,19 +65,7 @@ public Visibility State } } - protected override void Update() - { - base.Update(); - - if (dragRectangle != null) - PerformSelection?.Invoke(dragRectangle.Value); - } - - public override void Hide() - { - State = Visibility.Hidden; - dragRectangle = null; - } + public override void Hide() => State = Visibility.Hidden; public override void Show() => State = Visibility.Visible; diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 6a4fe27f0474..7423b368b4d8 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -27,6 +28,8 @@ public class EditorBlueprintContainer : BlueprintContainer private HitObjectUsageEventBuffer usageEventBuffer; + protected InputManager InputManager { get; private set; } + protected EditorBlueprintContainer(HitObjectComposer composer) { Composer = composer; @@ -42,6 +45,8 @@ protected override void LoadComplete() { base.LoadComplete(); + InputManager = GetContainingInputManager(); + Beatmap.HitObjectAdded += AddBlueprintFor; Beatmap.HitObjectRemoved += RemoveBlueprintFor; @@ -66,8 +71,6 @@ protected override void Update() protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints.OrderBy(b => b.Item.StartTime); - protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning; - protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) { if (!base.ApplySnapResult(blueprints, result)) @@ -133,8 +136,7 @@ protected override bool OnDoubleClick(DoubleClickEvent e) protected override void SelectAll() { Composer.Playfield.KeepAllAlive(); - - base.SelectAll(); + SelectedItems.AddRange(Beatmap.HitObjects.Except(SelectedItems).ToArray()); } protected override void OnBlueprintSelected(SelectionBlueprint blueprint) diff --git a/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs new file mode 100644 index 000000000000..58bfaf56ffdf --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs @@ -0,0 +1,64 @@ +// 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.Input.Events; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A that scrolls along with the scrolling playfield. + /// + public class ScrollingDragBox : DragBox + { + public double MinTime { get; private set; } + + public double MaxTime { get; private set; } + + private double? startTime; + + private readonly ScrollingPlayfield playfield; + + public ScrollingDragBox(Playfield playfield) + { + this.playfield = playfield as ScrollingPlayfield ?? throw new ArgumentException("Playfield must be of type {nameof(ScrollingPlayfield)} to use this class.", nameof(playfield)); + } + + public override void HandleDrag(MouseButtonEvent e) + { + base.HandleDrag(e); + + startTime ??= playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMouseDownPosition); + double endTime = playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMousePosition); + + MinTime = Math.Min(startTime.Value, endTime); + MaxTime = Math.Max(startTime.Value, endTime); + + var startPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(startTime.Value)); + var endPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(endTime)); + + switch (playfield.ScrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + Box.Y = Math.Min(startPos.Y, endPos.Y); + Box.Height = Math.Max(startPos.Y, endPos.Y) - Box.Y; + break; + + case ScrollingDirection.Left: + case ScrollingDirection.Right: + Box.X = Math.Min(startPos.X, endPos.X); + Box.Width = Math.Max(startPos.X, endPos.X) - Box.X; + break; + } + } + + public override void Hide() + { + base.Hide(); + startTime = null; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 8419d3b38043..269c19f846e7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -305,7 +305,7 @@ private bool ensureSelected(SelectionBlueprint blueprint) protected void DeleteSelected() { - DeleteItems(selectedBlueprints.Select(b => b.Item)); + DeleteItems(SelectedItems.ToArray()); } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 721f0c4e3b20..a73ada76f567 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -304,10 +304,20 @@ private void endUserDrag() /// public double VisibleRange => editorClock.TrackLength / Zoom; - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) => - new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); + public double TimeAtPosition(float x) + { + return x / Content.DrawWidth * editorClock.TrackLength; + } - private double getTimeFromPosition(Vector2 localPosition) => - (localPosition.X / Content.DrawWidth) * editorClock.TrackLength; + public float PositionAtTime(double time) + { + return (float)(time / editorClock.TrackLength * Content.DrawWidth); + } + + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) + { + double time = TimeAtPosition(Content.ToLocalSpace(screenSpacePosition).X); + return new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(time)); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index c2415ce9784a..58d378154a3f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -78,16 +78,16 @@ private void load(OverlayColourProvider colourProvider) LabelText = "Waveform", Current = { Value = true }, }, - controlPointsCheckbox = new OsuCheckbox + ticksCheckbox = new OsuCheckbox { - LabelText = "Control Points", + LabelText = "Ticks", Current = { Value = true }, }, - ticksCheckbox = new OsuCheckbox + controlPointsCheckbox = new OsuCheckbox { - LabelText = "Ticks", + LabelText = "BPM", Current = { Value = true }, - } + }, } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 590f92d2818c..b79c2675c817 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -3,7 +3,6 @@ #nullable disable -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -13,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; @@ -31,10 +29,11 @@ internal class TimelineBlueprintContainer : EditorBlueprintContainer [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - private DragEvent lastDragEvent; private Bindable placement; private SelectionBlueprint placementBlueprint; + private bool hitObjectDragged; + /// /// Positional input must be received outside the container's bounds, /// in order to handle timeline blueprints which are stacked offscreen. @@ -65,7 +64,6 @@ private void load() protected override void LoadComplete() { base.LoadComplete(); - DragBox.Alpha = 0; placement = Beatmap.PlacementObject.GetBoundCopy(); placement.ValueChanged += placementChanged; @@ -93,24 +91,18 @@ private void placementChanged(ValueChangedEvent obj) protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; - protected override void OnDrag(DragEvent e) + protected override bool OnDragStart(DragStartEvent e) { - handleScrollViaDrag(e); - - base.OnDrag(e); - } + if (!base.ReceivePositionalInputAt(e.ScreenSpaceMouseDownPosition)) + return false; - protected override void OnDragEnd(DragEndEvent e) - { - base.OnDragEnd(e); - lastDragEvent = null; + return base.OnDragStart(e); } protected override void Update() { - // trigger every frame so drags continue to update selection while playback is scrolling the timeline. - if (lastDragEvent != null) - OnDrag(lastDragEvent); + if (IsDragged || hitObjectDragged) + handleScrollViaDrag(); if (Composer != null && timeline != null) { @@ -165,32 +157,47 @@ protected override SelectionBlueprint CreateBlueprintFor(HitObject it { return new TimelineHitObjectBlueprint(item) { - OnDragHandled = handleScrollViaDrag, + OnDragHandled = e => hitObjectDragged = e != null, }; } - protected override DragBox CreateDragBox(Action performSelect) => new TimelineDragBox(performSelect); + protected sealed override DragBox CreateDragBox() => new TimelineDragBox(); - private void handleScrollViaDrag(DragEvent e) + protected override void UpdateSelectionFromDragBox() { - lastDragEvent = e; + var dragBox = (TimelineDragBox)DragBox; + double minTime = dragBox.MinTime; + double maxTime = dragBox.MaxTime; + + SelectedItems.RemoveAll(hitObject => !shouldBeSelected(hitObject)); - if (lastDragEvent == null) - return; + foreach (var hitObject in Beatmap.HitObjects.Except(SelectedItems).Where(shouldBeSelected)) + { + Composer.Playfield.SetKeepAlive(hitObject, true); + SelectedItems.Add(hitObject); + } - if (timeline != null) + bool shouldBeSelected(HitObject hitObject) { - var timelineQuad = timeline.ScreenSpaceDrawQuad; - float mouseX = e.ScreenSpaceMousePosition.X; - - // scroll if in a drag and dragging outside visible extents - if (mouseX > timelineQuad.TopRight.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); - else if (mouseX < timelineQuad.TopLeft.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + double midTime = (hitObject.StartTime + hitObject.GetEndTime()) / 2; + return minTime <= midTime && midTime <= maxTime; } } + private void handleScrollViaDrag() + { + if (timeline == null) return; + + var timelineQuad = timeline.ScreenSpaceDrawQuad; + float mouseX = InputManager.CurrentState.Mouse.Position.X; + + // scroll if in a drag and dragging outside visible extents + if (mouseX > timelineQuad.TopRight.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); + else if (mouseX < timelineQuad.TopLeft.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + } + private class SelectableAreaBackground : CompositeDrawable { [Resolved] diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs index c026c169d692..65d9293b7ece 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs @@ -6,76 +6,44 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.Utils; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class TimelineDragBox : DragBox { - // the following values hold the start and end X positions of the drag box in the timeline's local space, - // but with zoom unapplied in order to be able to compensate for positional changes - // while the timeline is being zoomed in/out. - private float? selectionStart; - private float selectionEnd; + public double MinTime { get; private set; } + + public double MaxTime { get; private set; } + + private double? startTime; [Resolved] private Timeline timeline { get; set; } - public TimelineDragBox(Action performSelect) - : base(performSelect) - { - } - protected override Drawable CreateBox() => new Box { RelativeSizeAxes = Axes.Y, Alpha = 0.3f }; - public override bool HandleDrag(MouseButtonEvent e) - { - // The dragbox should only be active if the mouseDownPosition.Y is within this drawable's bounds. - float localY = ToLocalSpace(e.ScreenSpaceMouseDownPosition).Y; - if (DrawRectangle.Top > localY || DrawRectangle.Bottom < localY) - return false; - - selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; - - // only calculate end when a transition is not in progress to avoid bouncing. - if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) - selectionEnd = e.MousePosition.X / timeline.CurrentZoom; - - updateDragBoxPosition(); - return true; - } - - private void updateDragBoxPosition() + public override void HandleDrag(MouseButtonEvent e) { - if (selectionStart == null) - return; - - float rescaledStart = selectionStart.Value * timeline.CurrentZoom; - float rescaledEnd = selectionEnd * timeline.CurrentZoom; - - Box.X = Math.Min(rescaledStart, rescaledEnd); - Box.Width = Math.Abs(rescaledStart - rescaledEnd); - - var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; + startTime ??= timeline.TimeAtPosition(e.MouseDownPosition.X); + double endTime = timeline.TimeAtPosition(e.MousePosition.X); - // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. - boxScreenRect.Y -= boxScreenRect.Height; - boxScreenRect.Height *= 2; + MinTime = Math.Min(startTime.Value, endTime); + MaxTime = Math.Max(startTime.Value, endTime); - PerformSelection?.Invoke(boxScreenRect); + Box.X = timeline.PositionAtTime(MinTime); + Box.Width = timeline.PositionAtTime(MaxTime) - Box.X; } public override void Hide() { base.Hide(); - selectionStart = null; + startTime = null; } } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 3dfc7010f360..912681e114da 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -304,6 +304,7 @@ private void load(OsuConfigManager config) cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut), copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy), pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste), + cloneMenuItem = new EditorMenuItem("Clone", MenuItemType.Standard, Clone), } }, new MenuItem("View") @@ -575,6 +576,10 @@ public bool OnPressed(KeyBindingPressEvent e) this.Exit(); return true; + case GlobalAction.EditorCloneSelection: + Clone(); + return true; + case GlobalAction.EditorComposeMode: Mode.Value = EditorScreenMode.Compose; return true; @@ -741,6 +746,7 @@ private void updateInProgress(ValueChangedEvent obj) private EditorMenuItem cutMenuItem; private EditorMenuItem copyMenuItem; + private EditorMenuItem cloneMenuItem; private EditorMenuItem pasteMenuItem; private readonly BindableWithCurrent canCut = new BindableWithCurrent(); @@ -750,7 +756,11 @@ private void updateInProgress(ValueChangedEvent obj) private void setUpClipboardActionAvailability() { canCut.Current.BindValueChanged(cut => cutMenuItem.Action.Disabled = !cut.NewValue, true); - canCopy.Current.BindValueChanged(copy => copyMenuItem.Action.Disabled = !copy.NewValue, true); + canCopy.Current.BindValueChanged(copy => + { + copyMenuItem.Action.Disabled = !copy.NewValue; + cloneMenuItem.Action.Disabled = !copy.NewValue; + }, true); canPaste.Current.BindValueChanged(paste => pasteMenuItem.Action.Disabled = !paste.NewValue, true); } @@ -765,6 +775,21 @@ private void rebindClipboardBindables() protected void Copy() => currentScreen?.Copy(); + protected void Clone() + { + // Avoid attempting to clone if copying is not available (as it may result in pasting something unexpected). + if (!canCopy.Value) + return; + + // This is an initial implementation just to get an idea of how people used this function. + // There are a couple of differences from osu!stable's implementation which will require more work to match: + // - The "clipboard" is not populated during the duplication process. + // - The duplicated hitobjects are inserted after the original pattern (add one beat_length and then quantize using beat snap). + // - The duplicated hitobjects are selected (but this is also applied for all paste operations so should be changed there). + Copy(); + Paste(); + } + protected void Paste() => currentScreen?.Paste(); #endregion diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 16c0064e80cf..839535b99f12 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -352,6 +352,8 @@ protected override void UpdateState() var updates = batchPendingUpdates.ToArray(); batchPendingUpdates.Clear(); + foreach (var h in deletes) SelectedHitObjects.Remove(h); + foreach (var h in deletes) HitObjectRemoved?.Invoke(h); foreach (var h in inserts) HitObjectAdded?.Invoke(h); foreach (var h in updates) HitObjectUpdated?.Invoke(h); diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 7d8657a3df7d..a5739a41b188 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -41,6 +41,11 @@ public interface IOsuScreen : IScreen /// bool HideOverlaysOnEnter { get; } + /// + /// Whether the menu cursor should be hidden when non-mouse input is received. + /// + bool HideMenuCursorOnNonMouseInput { get; } + /// /// Whether overlays should be able to be opened when this screen is current. /// diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index 409c7d6c8dfc..dcead4a3a861 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -278,11 +278,11 @@ protected void StartTrack() if (!UsingThemedIntro) { - initialBeatmap?.PrepareTrackForPreview(false); + initialBeatmap?.PrepareTrackForPreview(false, -2600); drawableTrack.VolumeTo(0); drawableTrack.Restart(); - drawableTrack.VolumeTo(1, 2200, Easing.InCubic); + drawableTrack.VolumeTo(1, 2600, Easing.InCubic); } else { diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index 9e56a3a0b748..5ae2158172b9 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -78,13 +78,17 @@ protected override void LogoArriving(OsuLogo logo, bool resuming) if (reverbChannel != null) intro.LogoVisualisation.AddAmplitudeSource(reverbChannel); - Scheduler.AddDelayed(() => - { + if (!UsingThemedIntro) StartTrack(); - // this classic intro loops forever. + Scheduler.AddDelayed(() => + { if (UsingThemedIntro) + { + StartTrack(); + // this classic intro loops forever. Track.Looping = true; + } const float fade_in_time = 200; diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index e9b50f94f7c3..3efd74d2c8eb 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -113,7 +113,7 @@ public OsuLogo() AutoSizeAxes = Axes.Both, Children = new Drawable[] { - logoBounceContainer = new DragContainer + logoBounceContainer = new Container { AutoSizeAxes = Axes.Both, Children = new Drawable[] @@ -407,27 +407,24 @@ public void Impact() impactContainer.ScaleTo(1.12f, 250); } - private class DragContainer : Container - { - public override bool DragBlocksClick => false; + public override bool DragBlocksClick => false; - protected override bool OnDragStart(DragStartEvent e) => true; + protected override bool OnDragStart(DragStartEvent e) => true; - protected override void OnDrag(DragEvent e) - { - Vector2 change = e.MousePosition - e.MouseDownPosition; + protected override void OnDrag(DragEvent e) + { + Vector2 change = e.MousePosition - e.MouseDownPosition; - // Diminish the drag distance as we go further to simulate "rubber band" feeling. - change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.6f) / change.Length; + // Diminish the drag distance as we go further to simulate "rubber band" feeling. + change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.6f) / change.Length; - this.MoveTo(change); - } + logoBounceContainer.MoveTo(change); + } - protected override void OnDragEnd(DragEndEvent e) - { - this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); - base.OnDragEnd(e); - } + protected override void OnDragEnd(DragEndEvent e) + { + logoBounceContainer.MoveTo(Vector2.Zero, 800, Easing.OutElastic); + base.OnDragEnd(e); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index 39740e650f23..ba6b48272969 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -78,9 +78,13 @@ private void updateDeleteButtonVisibility() return; bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost; + bool isValidItem = isItemOwner && !Item.Expired; - AllowDeletion = isItemOwner && !Item.Expired && Item.ID != multiplayerClient.Room.Settings.PlaylistItemId; - AllowEditing = isItemOwner && !Item.Expired; + AllowDeletion = isValidItem + && (Item.ID != multiplayerClient.Room.Settings.PlaylistItemId // This is an optimisation for the following check. + || multiplayerClient.Room.Playlist.Count(i => !i.Expired) > 1); + + AllowEditing = isValidItem; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index 41633c34ce20..27193d3cb643 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -182,7 +182,7 @@ private APIRequest createIndexRequest(Action> scoresCallb /// An optional pivot around which the scores were retrieved. private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) => Schedule(() => { - var scoreInfos = scoreManager.OrderByTotalScore(scores.Select(s => s.CreateScoreInfo(rulesets, playlistItem, Beatmap.Value.BeatmapInfo))).ToArray(); + var scoreInfos = scoreManager.OrderByTotalScore(scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo))).ToArray(); // Select a score if we don't already have one selected. // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 0678a90f71a9..6be13bbda3a1 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -40,11 +40,10 @@ public abstract class OsuScreen : Screen, IOsuScreen, IHasDescription public virtual bool AllowExternalScreenChange => false; - /// - /// Whether all overlays should be hidden when this screen is entered or resumed. - /// public virtual bool HideOverlaysOnEnter => false; + public virtual bool HideMenuCursorOnNonMouseInput => false; + /// /// The initial overlay activation mode to use when this screen is entered for the first time. /// diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs new file mode 100644 index 000000000000..4179d4164670 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ComboCounter.cs @@ -0,0 +1,24 @@ +// 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.Game.Graphics.UserInterface; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract class ComboCounter : RollingCounter, ISkinnableDrawable + { + public bool UsesFixedAnchor { get; set; } + + protected ComboCounter() + { + Current.Value = DisplayedCount = 0; + } + + protected override double GetProportionalDuration(int currentValue, int newValue) + { + return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 1f148111695b..0c9c3632800d 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -1,29 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, ISkinnableDrawable + public class DefaultComboCounter : ComboCounter { - public bool UsesFixedAnchor { get; set; } - - public DefaultComboCounter() - { - Current.Value = DisplayedCount = 0; - } - [BackgroundDependencyLoader] private void load(OsuColour colours, ScoreProcessor scoreProcessor) { @@ -31,17 +19,12 @@ private void load(OsuColour colours, ScoreProcessor scoreProcessor) Current.BindTo(scoreProcessor.Combo); } + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); + protected override LocalisableString FormatCount(int count) { return $@"{count}x"; } - - protected override double GetProportionalDuration(int currentValue, int newValue) - { - return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; - } - - protected override OsuSpriteText CreateSpriteText() - => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); } } diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index fde895a1cae5..48908fb9a048 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -124,7 +124,7 @@ protected override void Update() float fadeBottom = scroll.Current + scroll.DrawHeight; float fadeTop = scroll.Current + panel_height; - if (scroll.Current <= 0) fadeTop -= panel_height; + if (scroll.IsScrolledToStart()) fadeTop -= panel_height; if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; // logic is mostly shared with Leaderboard, copied here for simplicity. diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 29354e610d9d..2eec8253b3b3 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -39,8 +40,6 @@ public class GameplayLeaderboardScore : CompositeDrawable, ILeaderboardScore private const float rank_text_width = 35f; - private const float score_components_width = 85f; - private const float avatar_size = 25f; private const double panel_transition_duration = 500; @@ -161,7 +160,7 @@ private void load(OsuColour colours) { new Dimension(GridSizeMode.Absolute, rank_text_width), new Dimension(), - new Dimension(GridSizeMode.AutoSize, maxSize: score_components_width), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { @@ -286,8 +285,19 @@ private void load(OsuColour colours) LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); TotalScore.BindValueChanged(v => scoreText.Text = v.NewValue.ToString("N0"), true); - Accuracy.BindValueChanged(v => accuracyText.Text = v.NewValue.FormatAccuracy(), true); - Combo.BindValueChanged(v => comboText.Text = $"{v.NewValue}x", true); + + Accuracy.BindValueChanged(v => + { + accuracyText.Text = v.NewValue.FormatAccuracy(); + updateDetailsWidth(); + }, true); + + Combo.BindValueChanged(v => + { + comboText.Text = $"{v.NewValue}x"; + updateDetailsWidth(); + }, true); + HasQuit.BindValueChanged(_ => updateState()); } @@ -303,13 +313,10 @@ protected override void LoadComplete() private void changeExpandedState(ValueChangedEvent expanded) { - scoreComponents.ClearTransforms(); - if (expanded.NewValue) { gridContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutQuint); - scoreComponents.ResizeWidthTo(score_components_width, panel_transition_duration, Easing.OutQuint); scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint); usernameText.FadeIn(panel_transition_duration, Easing.OutQuint); @@ -318,11 +325,29 @@ private void changeExpandedState(ValueChangedEvent expanded) { gridContainer.ResizeWidthTo(compact_width, panel_transition_duration, Easing.OutQuint); - scoreComponents.ResizeWidthTo(0, panel_transition_duration, Easing.OutQuint); scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint); usernameText.FadeOut(text_transition_duration, Easing.OutQuint); } + + updateDetailsWidth(); + } + + private float? scoreComponentsTargetWidth; + + private void updateDetailsWidth() + { + const float score_components_min_width = 88f; + + float newWidth = Expanded.Value + ? Math.Max(score_components_min_width, comboText.DrawWidth + accuracyText.DrawWidth + 25) + : 0; + + if (scoreComponentsTargetWidth == newWidth) + return; + + scoreComponentsTargetWidth = newWidth; + scoreComponents.ResizeWidthTo(newWidth, panel_transition_duration, Easing.OutQuint); } private void updateState() diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 747f4d4a8a62..d6b9c6236925 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -44,8 +45,8 @@ public class BarHitErrorMeter : HitErrorMeter public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons); private SpriteIcon arrow; - private Drawable labelEarly; - private Drawable labelLate; + private UprightAspectMaintainingContainer labelEarly; + private UprightAspectMaintainingContainer labelLate; private Container colourBarsEarly; private Container colourBarsLate; @@ -122,6 +123,20 @@ private void load() RelativeSizeAxes = Axes.Y, Width = judgement_line_width, }, + labelEarly = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -10, + }, + labelLate = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Y = 10, + }, } }, arrowContainer = new Container @@ -261,57 +276,39 @@ private void recreateLabels(LabelStyles style) { const float icon_size = 14; - labelEarly?.Expire(); - labelEarly = null; - - labelLate?.Expire(); - labelLate = null; - switch (style) { case LabelStyles.None: break; case LabelStyles.Icons: - labelEarly = new SpriteIcon + labelEarly.Child = new SpriteIcon { - Y = -10, Size = new Vector2(icon_size), Icon = FontAwesome.Solid.ShippingFast, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, }; - labelLate = new SpriteIcon + labelLate.Child = new SpriteIcon { - Y = 10, Size = new Vector2(icon_size), Icon = FontAwesome.Solid.Bicycle, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, }; break; case LabelStyles.Text: - labelEarly = new OsuSpriteText + labelEarly.Child = new OsuSpriteText { - Y = -10, Text = "Early", Font = OsuFont.Default.With(size: 10), Height = 12, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, }; - labelLate = new OsuSpriteText + labelLate.Child = new OsuSpriteText { - Y = 10, Text = "Late", Font = OsuFont.Default.With(size: 10), Height = 12, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, }; break; @@ -320,26 +317,8 @@ private void recreateLabels(LabelStyles style) throw new ArgumentOutOfRangeException(nameof(style), style, null); } - if (labelEarly != null) - { - colourBars.Add(labelEarly); - labelEarly.FadeInFromZero(500); - } - - if (labelLate != null) - { - colourBars.Add(labelLate); - labelLate.FadeInFromZero(500); - } - } - - protected override void Update() - { - base.Update(); - - // undo any layout rotation to display icons in the correct orientation - if (labelEarly != null) labelEarly.Rotation = -Rotation; - if (labelLate != null) labelLate.Rotation = -Rotation; + labelEarly.FadeInFromZero(500); + labelLate.FadeInFromZero(500); } private void createColourBars((HitResult result, double length)[] windows) diff --git a/osu.Game/Screens/Play/HUD/LongestComboCounter.cs b/osu.Game/Screens/Play/HUD/LongestComboCounter.cs new file mode 100644 index 000000000000..0e7af69af2e9 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/LongestComboCounter.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class LongestComboCounter : ComboCounter + { + [BackgroundDependencyLoader] + private void load(OsuColour colours, ScoreProcessor scoreProcessor) + { + Colour = colours.YellowLighter; + Current.BindTo(scoreProcessor.HighestCombo); + } + + protected override IHasText CreateText() => new TextComponent(); + + private class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = $"{value}x"; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 20) + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"longest", + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"combo", + Padding = new MarginPadding { Bottom = 3f } + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs index 53981b9edcf0..ab3cf2950c77 100644 --- a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs @@ -87,7 +87,7 @@ private void showScores() local.TotalScore.BindTarget = scoreProcessor.TotalScore; local.Accuracy.BindTarget = scoreProcessor.Accuracy; - local.Combo.BindTarget = scoreProcessor.Combo; + local.Combo.BindTarget = scoreProcessor.HighestCombo; // Local score should always show lower than any existing scores in cases of ties. local.DisplayOrder.Value = long.MaxValue; diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 3fbb051c3b85..7833c2d7fa18 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -39,6 +39,10 @@ public class HUDOverlay : Container, IKeyBindingHandler /// public float BottomScoringElementsHeight { get; private set; } + // HUD uses AlwaysVisible on child components so they can be in an updated state for next display. + // Without blocking input, this would also allow them to be interacted with in such a state. + public override bool PropagatePositionalInputSubTree => ShowHud.Value; + public readonly KeyCounterDisplay KeyCounter; public readonly ModDisplay ModDisplay; public readonly HoldForMenuButton HoldToQuit; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index e31e18046e46..7048f83c09c1 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -66,6 +66,8 @@ public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisab public override bool HideOverlaysOnEnter => true; + public override bool HideMenuCursorOnNonMouseInput => true; + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). @@ -94,6 +96,11 @@ public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisab public int RestartCount; + /// + /// Whether the is currently visible. + /// + public IBindable ShowingOverlayComponents = new Bindable(); + [Resolved] private ScoreManager scoreManager { get; set; } @@ -566,9 +573,6 @@ private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods, CancellationToken cance /// protected void PerformExit(bool showDialogFirst) { - // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). - resultsDisplayDelegate?.Cancel(); - // there is a chance that an exit request occurs after the transition to results has already started. // even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process). if (!this.IsCurrentScreen()) @@ -603,6 +607,9 @@ protected void PerformExit(bool showDialogFirst) } } + // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). + resultsDisplayDelegate?.Cancel(); + // The actual exit is performed if // - the pause / fail dialog was not requested // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). @@ -780,19 +787,11 @@ private async Task prepareAndImportScore() /// /// /// A final display will only occur once all work is completed in . This means that even after calling this method, the results screen will never be shown until ScoreProcessor.HasCompleted becomes . - /// - /// Calling this method multiple times will have no effect. /// /// Whether a minimum delay () should be added before the screen is displayed. private void progressToResults(bool withDelay) { - if (resultsDisplayDelegate != null) - // Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be - // accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued - // may take x00 more milliseconds than expected in the very rare edge case). - // - // If required we can handle this more correctly by rescheduling here. - return; + resultsDisplayDelegate?.Cancel(); double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0; @@ -1023,6 +1022,8 @@ public override void OnEntering(ScreenTransitionEvent e) }); HUDOverlay.IsPlaying.BindTo(localUserPlaying); + ShowingOverlayComponents.BindTo(HUDOverlay.ShowHud); + DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index e32d3d90be87..4ff5083107dd 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -64,6 +64,8 @@ public class PlayerLoader : ScreenWithBeatmapBackground protected Task? DisposalTask { get; private set; } + private OsuScrollContainer settingsScroll = null!; + private bool backgroundBrightnessReduction; private readonly BindableDouble volumeAdjustment = new BindableDouble(1); @@ -71,6 +73,9 @@ public class PlayerLoader : ScreenWithBeatmapBackground private AudioFilter lowPassFilter = null!; private AudioFilter highPassFilter = null!; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + protected bool BackgroundBrightnessReduction { set @@ -165,30 +170,30 @@ private void load(SessionStatics sessionStatics, AudioManager audio) Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - new OsuScrollContainer + }), + settingsScroll = new OsuScrollContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, + Padding = new MarginPadding { Vertical = padding }, + Masking = false, + Child = PlayerSettings = new FillFlowContainer { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, - Padding = new MarginPadding { Vertical = padding }, - Masking = false, - Child = PlayerSettings = new FillFlowContainer + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Padding = new MarginPadding { Horizontal = padding }, + Children = new PlayerSettingsGroup[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Padding = new MarginPadding { Horizontal = padding }, - Children = new PlayerSettingsGroup[] - { - VisualSettings = new VisualSettings(), - AudioSettings = new AudioSettings(), - new InputSettings() - } - }, + VisualSettings = new VisualSettings(), + AudioSettings = new AudioSettings(), + new InputSettings() + } }, - idleTracker = new IdleTracker(750), - }), + }, + idleTracker = new IdleTracker(750), lowPassFilter = new AudioFilter(audio.TrackMixer), highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass) }; @@ -224,6 +229,9 @@ public override void OnEntering(ScreenTransitionEvent e) Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + // Start off-screen. + settingsScroll.MoveToX(settingsScroll.DrawWidth); + content.ScaleTo(0.7f); contentIn(); @@ -313,6 +321,16 @@ protected override void LogoExiting(OsuLogo logo) content.StopTracking(); } + protected override void LogoSuspending(OsuLogo logo) + { + base.LogoSuspending(logo); + content.StopTracking(); + + logo + .FadeOut(CONTENT_OUT_DURATION / 2, Easing.OutQuint) + .ScaleTo(logo.Scale * 0.8f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + } + #endregion protected override void Update() @@ -391,6 +409,10 @@ private void contentIn() content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + + settingsScroll.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); highPassFilter.CutoffTo(300).Then().CutoffTo(0, 1250); // 1250 is to line up with the appearance of MetadataInfo (750 delay + 500 fade-in) @@ -404,6 +426,10 @@ protected virtual void ContentOut() content.ScaleTo(0.7f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); content.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint); + + settingsScroll.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) + .MoveToX(settingsScroll.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION); highPassFilter.CutoffTo(0, CONTENT_OUT_DURATION); } @@ -432,7 +458,7 @@ private void pushWhenLoaded() ContentOut(); - TransformSequence pushSequence = this.Delay(CONTENT_OUT_DURATION); + TransformSequence pushSequence = this.Delay(0); // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). @@ -441,6 +467,7 @@ private void pushWhenLoaded() const double epilepsy_display_length = 3000; pushSequence + .Delay(CONTENT_OUT_DURATION) .Schedule(() => epilepsyWarning.State.Value = Visibility.Visible) .TransformBindableTo(volumeAdjustment, 0.25, EpilepsyWarning.FADE_DURATION, Easing.OutQuint) .Delay(epilepsy_display_length) diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index be77304076de..345bd5a13440 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -76,6 +76,7 @@ private bool handleTokenRetrieval() req.Success += r => { + Logger.Log($"Score submission token retrieved ({r.ID})"); token = r.ID; tcs.SetResult(true); }; @@ -85,12 +86,14 @@ private bool handleTokenRetrieval() // Generally a timeout would not happen here as APIAccess will timeout first. if (!tcs.Task.Wait(60000)) - handleTokenFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); + req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); return true; void handleTokenFailure(Exception exception) { + tcs.SetResult(false); + if (HandleTokenRetrievalFailure(exception)) { if (string.IsNullOrEmpty(exception.Message)) @@ -104,8 +107,12 @@ void handleTokenFailure(Exception exception) this.Exit(); }); } - - tcs.SetResult(false); + else + { + // Gameplay is allowed to continue, but we still should keep track of the error. + // In the future, this should be visible to the user in some way. + Logger.Log($"Score submission token retrieval failed ({exception.Message})"); + } } } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index a8cb06b88801..3b694dbf4357 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -1048,7 +1048,7 @@ public override void RemoveItem(CarouselItem i) protected override void PerformSelection() { - if (LastSelected == null || LastSelected.Filtered.Value) + if (LastSelected == null) carousel?.SelectNextRandom(); else base.PerformSelection(); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index 61109829f33a..6366fc80508b 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -108,10 +108,35 @@ private void attemptSelection() PerformSelection(); } + /// + /// Finds the item this group would select next if it attempted selection + /// + /// An unfiltered item nearest to the last selected one or null if all items are filtered protected virtual CarouselItem GetNextToSelect() { - return Items.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? - Items.Reverse().Skip(Items.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + if (Items.Count == 0) + return null; + + int forwardsIndex = lastSelectedIndex; + int backwardsIndex = Math.Min(lastSelectedIndex, Items.Count - 1); + + while (true) + { + bool hasBackwards = backwardsIndex >= 0 && backwardsIndex < Items.Count; + bool hasForwards = forwardsIndex < Items.Count; + + if (!hasBackwards && !hasForwards) + return null; + + if (hasForwards && !Items[forwardsIndex].Filtered.Value) + return Items[forwardsIndex]; + + if (hasBackwards && !Items[backwardsIndex].Filtered.Value) + return Items[backwardsIndex]; + + forwardsIndex++; + backwardsIndex--; + } } protected virtual void PerformSelection() diff --git a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs index 3c4ed4734ba6..1bb607bcf3aa 100644 --- a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs +++ b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs @@ -2,12 +2,14 @@ // 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.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -30,9 +32,12 @@ public class UpdateBeatmapSetButton : OsuAnimatedButton [Resolved] private IAPIProvider api { get; set; } = null!; - [Resolved(canBeNull: true)] + [Resolved] private LoginOverlay? loginOverlay { get; set; } + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + public UpdateBeatmapSetButton(BeatmapSetInfo beatmapSetInfo) { this.beatmapSetInfo = beatmapSetInfo; @@ -43,11 +48,15 @@ public UpdateBeatmapSetButton(BeatmapSetInfo beatmapSetInfo) Origin = Anchor.CentreLeft; } + private Bindable preferNoVideo = null!; + [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { const float icon_size = 14; + preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); + Content.Anchor = Anchor.CentreLeft; Content.Origin = Anchor.CentreLeft; @@ -96,17 +105,34 @@ private void load() }, }); - Action = () => + Action = updateBeatmap; + } + + private bool updateConfirmed; + + private void updateBeatmap() + { + if (!api.IsLoggedIn) + { + loginOverlay?.Show(); + return; + } + + if (dialogOverlay != null && beatmapSetInfo.Status == BeatmapOnlineStatus.LocallyModified && !updateConfirmed) { - if (!api.IsLoggedIn) + dialogOverlay.Push(new UpdateLocalConfirmationDialog(() => { - loginOverlay?.Show(); - return; - } + updateConfirmed = true; + updateBeatmap(); + })); + + return; + } + + updateConfirmed = false; - beatmapDownloader.DownloadAsUpdate(beatmapSetInfo); - attachExistingDownload(); - }; + beatmapDownloader.DownloadAsUpdate(beatmapSetInfo, preferNoVideo.Value); + attachExistingDownload(); } protected override void LoadComplete() diff --git a/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs b/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs new file mode 100644 index 000000000000..f5267e905e52 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Select.Carousel +{ + public class UpdateLocalConfirmationDialog : DeleteConfirmationDialog + { + public UpdateLocalConfirmationDialog(Action onConfirm) + { + HeaderText = PopupDialogStrings.UpdateLocallyModifiedText; + BodyText = PopupDialogStrings.UpdateLocallyModifiedDescription; + Icon = FontAwesome.Solid.ExclamationTriangle; + DeleteAction = onConfirm; + } + } +} diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index 1f56915f6231..aad7fdff39a7 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -138,7 +138,8 @@ public override bool OnPressed(KeyBindingPressEvent e) return false; } - TriggerClick(); + if (!e.Repeat) + TriggerClick(); return true; } diff --git a/osu.Game/Screens/Utility/LatencyArea.cs b/osu.Game/Screens/Utility/LatencyArea.cs index c8e0bf93a205..b7d45ba6428c 100644 --- a/osu.Game/Screens/Utility/LatencyArea.cs +++ b/osu.Game/Screens/Utility/LatencyArea.cs @@ -36,7 +36,7 @@ public class LatencyArea : CompositeDrawable, IProvideCursor public readonly Bindable VisualMode = new Bindable(); - public CursorContainer? MenuCursor { get; private set; } + public CursorContainer? Cursor { get; private set; } public bool ProvidingUserCursor => IsActiveArea.Value; @@ -91,7 +91,7 @@ protected override void LoadComplete() { RelativeSizeAxes = Axes.Both, }, - MenuCursor = new LatencyCursorContainer + Cursor = new LatencyCursorContainer { RelativeSizeAxes = Axes.Both, }, @@ -105,7 +105,7 @@ protected override void LoadComplete() { RelativeSizeAxes = Axes.Both, }, - MenuCursor = new LatencyCursorContainer + Cursor = new LatencyCursorContainer { RelativeSizeAxes = Axes.Both, }, @@ -119,7 +119,7 @@ protected override void LoadComplete() { RelativeSizeAxes = Axes.Both, }, - MenuCursor = new LatencyCursorContainer + Cursor = new LatencyCursorContainer { RelativeSizeAxes = Axes.Both, }, diff --git a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs index c9d4dc78116c..bacaccd68e4a 100644 --- a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs +++ b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs @@ -261,8 +261,8 @@ private void showResults() string exclusive = "unknown"; - if (host.Window is WindowsWindow windowsWindow) - exclusive = windowsWindow.FullscreenCapability.ToString(); + if (host.Renderer is IWindowsRenderer windowsRenderer) + exclusive = windowsRenderer.FullscreenCapability.ToString(); statusText.Clear(); diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index abc7b6103608..f0caef9fa17d 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -67,9 +67,12 @@ protected override bool AllowSampleLookup(ISampleInfo sampleInfo) return sampleInfo is StoryboardSampleInfo || beatmapHitsounds.Value; } + private readonly ISkin skin; + public BeatmapSkinProvidingContainer(ISkin skin) : base(skin) { + this.skin = skin; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -84,11 +87,21 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl } [BackgroundDependencyLoader] - private void load() + private void load(SkinManager skins) { beatmapSkins.BindValueChanged(_ => TriggerSourceChanged()); beatmapColours.BindValueChanged(_ => TriggerSourceChanged()); beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); + + // If the beatmap skin looks to have skinnable resources, add the default classic skin as a fallback opportunity. + if (skin is LegacySkinTransformer legacySkin && legacySkin.IsProvidingLegacyResources) + { + SetSources(new[] + { + skin, + skins.DefaultClassicSkin + }); + } } } } diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index 04f1286dc7d7..b80275a1e85e 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -46,6 +46,8 @@ public DefaultLegacySkin(SkinInfo skin, IStorageResourceProvider resources) new Color4(242, 24, 57, 255) }; + Configuration.ConfigDictionary[nameof(SkinConfiguration.LegacySetting.AllowSliderBallTint)] = @"true"; + Configuration.LegacyVersion = 2.7m; } } diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs index 5a1ef3415150..2937b62eec67 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -117,6 +117,11 @@ protected override bool OnKeyDown(KeyDownEvent e) return false; } + protected override void SelectAll() + { + SelectedItems.AddRange(targetComponents.SelectMany(list => list).Except(SelectedItems).ToArray()); + } + /// /// Move the current selection spatially by the specified delta, in screen coordinates (ie. the same coordinates as the blueprints). /// diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index 980dee860142..469657c03c91 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -85,10 +85,6 @@ public class ToolboxComponentButton : OsuButton { public Action? RequestPlacement; - protected override bool ShouldBeConsideredForInput(Drawable child) => false; - - public override bool PropagateNonPositionalInputSubTree => false; - private readonly Drawable component; private readonly CompositeDrawable? dependencySource; @@ -177,6 +173,10 @@ protected override bool OnClick(ClickEvent e) public class DependencyBorrowingContainer : Container { + protected override bool ShouldBeConsideredForInput(Drawable child) => false; + + public override bool PropagateNonPositionalInputSubTree => false; + private readonly CompositeDrawable? donor; public DependencyBorrowingContainer(CompositeDrawable? donor) diff --git a/osu.Game/Skinning/HUDSkinComponents.cs b/osu.Game/Skinning/HUDSkinComponents.cs deleted file mode 100644 index 586882d7908d..000000000000 --- a/osu.Game/Skinning/HUDSkinComponents.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -namespace osu.Game.Skinning -{ - public enum HUDSkinComponents - { - ComboCounter, - ScoreCounter, - AccuracyCounter, - HealthDisplay, - SongProgress, - BarHitErrorMeter, - ColourHitErrorMeter, - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs b/osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs similarity index 81% rename from osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs rename to osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs index 152ed5c3d9d7..2bcdd5b5a1a0 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs +++ b/osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs @@ -7,15 +7,15 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; -namespace osu.Game.Rulesets.Osu.Skinning.Legacy +namespace osu.Game.Skinning { - internal class KiaiFlashingDrawable : BeatSyncedContainer + public class LegacyKiaiFlashingDrawable : BeatSyncedContainer { private readonly Drawable flashingDrawable; - private const float flash_opacity = 0.3f; + private const float flash_opacity = 0.55f; - public KiaiFlashingDrawable(Func creationFunc) + public LegacyKiaiFlashingDrawable(Func creationFunc) { AutoSizeAxes = Axes.Both; @@ -44,7 +44,7 @@ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, flashingDrawable .FadeTo(flash_opacity) .Then() - .FadeOut(timingPoint.BeatLength * 0.75f); + .FadeOut(Math.Max(80, timingPoint.BeatLength - 80), Easing.OutSine); } } } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index 45454be4a527..3ec0ee6006ae 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -1,21 +1,34 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Skinning { + /// + /// This class exists for the explicit purpose of ferrying information from ManiaBeatmap in a way LegacySkin can use it. + /// This is because half of the mania legacy skin implementation is in LegacySkin (osu.Game project) which doesn't have visibility + /// over ManiaBeatmap / StageDefinition. + /// public class LegacyManiaSkinConfigurationLookup { - public readonly int Keys; + /// + /// Total columns across all stages. + /// + public readonly int TotalColumns; + + /// + /// The column which is being looked up. + /// May be null if the configuration does not apply to a specific column. + /// Note that this is the absolute index across all stages. + /// + public readonly int? ColumnIndex; + public readonly LegacyManiaSkinConfigurationLookups Lookup; - public readonly int? TargetColumn; - public LegacyManiaSkinConfigurationLookup(int keys, LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + public LegacyManiaSkinConfigurationLookup(int totalColumns, LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) { - Keys = keys; + TotalColumns = totalColumns; Lookup = lookup; - TargetColumn = targetColumn; + ColumnIndex = columnIndex; } } @@ -29,6 +42,8 @@ public enum LegacyManiaSkinConfigurationLookups HitPosition, ScorePosition, LightPosition, + StagePaddingTop, + StagePaddingBottom, HitTargetImage, ShowJudgementLine, KeyImage, diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 1e096702b372..eaca0de11aee 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -128,18 +128,18 @@ protected override void ParseConfigurationStream(Stream stream) private IBindable? lookupForMania(LegacyManiaSkinConfigurationLookup maniaLookup) { - if (!maniaConfigurations.TryGetValue(maniaLookup.Keys, out var existing)) - maniaConfigurations[maniaLookup.Keys] = existing = new LegacyManiaSkinConfiguration(maniaLookup.Keys); + if (!maniaConfigurations.TryGetValue(maniaLookup.TotalColumns, out var existing)) + maniaConfigurations[maniaLookup.TotalColumns] = existing = new LegacyManiaSkinConfiguration(maniaLookup.TotalColumns); switch (maniaLookup.Lookup) { case LegacyManiaSkinConfigurationLookups.ColumnWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); @@ -157,15 +157,15 @@ protected override void ParseConfigurationStream(Stream stream) return SkinUtils.As(getManiaImage(existing, "LightingN")); case LegacyManiaSkinConfigurationLookups.ExplosionScale: - Debug.Assert(maniaLookup.TargetColumn != null); + Debug.Assert(maniaLookup.ColumnIndex != null); if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); - if (existing.ExplosionWidth[maniaLookup.TargetColumn.Value] != 0) - return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.ColumnLineColour: return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); @@ -174,53 +174,53 @@ protected override void ParseConfigurationStream(Stream stream) return SkinUtils.As(getCustomColour(existing, "ColourJudgementLine")); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.TargetColumn + 1}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.ColumnIndex + 1}")); case LegacyManiaSkinConfigurationLookups.ColumnLightColour: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.TargetColumn + 1}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.ColumnIndex + 1}")); case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); case LegacyManiaSkinConfigurationLookups.NoteImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}")); case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}H")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}H")); case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}T")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}T")); case LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}L")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}L")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: return SkinUtils.As(getManiaImage(existing, "LightingL")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: - Debug.Assert(maniaLookup.TargetColumn != null); + Debug.Assert(maniaLookup.ColumnIndex != null); if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); - if (existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] != 0) - return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.KeyImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}")); case LegacyManiaSkinConfigurationLookups.KeyImageDown: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}D")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}D")); case LegacyManiaSkinConfigurationLookups.LeftStageImage: return SkinUtils.As(getManiaImage(existing, "StageLeft")); @@ -238,12 +238,12 @@ protected override void ParseConfigurationStream(Stream stream) return SkinUtils.As(getManiaImage(existing, "StageHint")); case LegacyManiaSkinConfigurationLookups.LeftLineWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.RightLineWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value + 1])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); case LegacyManiaSkinConfigurationLookups.Hit0: case LegacyManiaSkinConfigurationLookups.Hit50: @@ -389,18 +389,17 @@ protected override void ParseConfigurationStream(Stream stream) if (particle != null) return new LegacyJudgementPieceNew(resultComponent.Component, createDrawable, particle); - else - return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); + + return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); } return null; case SkinnableSprite.SpriteComponent sprite: return this.GetAnimation(sprite.LookupName, false, false); - - default: - throw new UnsupportedSkinComponentException(component); } + + return null; } private Texture? getParticleTexture(HitResult result) diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index 2de1564a5cc4..367e5bae0178 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -13,6 +13,11 @@ namespace osu.Game.Skinning /// public abstract class LegacySkinTransformer : SkinTransformer { + /// + /// Whether the skin being transformed is able to provide legacy resources for the ruleset. + /// + public virtual bool IsProvidingLegacyResources => this.HasFont(LegacyFont.Combo); + protected LegacySkinTransformer(ISkin skin) : base(skin) { diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index 6ad5d64e4b46..7267ebd92dc2 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -41,7 +41,7 @@ public RulesetSkinProvidingContainer(Ruleset ruleset, IBeatmap beatmap, [CanBeNu Ruleset = ruleset; Beatmap = beatmap; - InternalChild = new BeatmapSkinProvidingContainer(beatmapSkin is LegacySkin ? GetRulesetTransformedSkin(beatmapSkin) : beatmapSkin) + InternalChild = new BeatmapSkinProvidingContainer(GetRulesetTransformedSkin(beatmapSkin)) { Child = Content = new Container { diff --git a/osu.Game/Skinning/SkinConfiguration.cs b/osu.Game/Skinning/SkinConfiguration.cs index 0b1159f8fd16..4e5d96ccb890 100644 --- a/osu.Game/Skinning/SkinConfiguration.cs +++ b/osu.Game/Skinning/SkinConfiguration.cs @@ -38,7 +38,8 @@ public enum LegacySetting HitCirclePrefix, HitCircleOverlap, AnimationFramerate, - LayeredHitSounds + LayeredHitSounds, + AllowSliderBallTint, } public static List DefaultComboColours { get; } = new List diff --git a/osu.Game/Storyboards/CommandTimeline.cs b/osu.Game/Storyboards/CommandTimeline.cs index 4d0da9597b4f..d1a1edcd0331 100644 --- a/osu.Game/Storyboards/CommandTimeline.cs +++ b/osu.Game/Storyboards/CommandTimeline.cs @@ -27,7 +27,10 @@ public class CommandTimeline : ICommandTimeline public void Add(Easing easing, double startTime, double endTime, T startValue, T endValue) { if (endTime < startTime) - return; + { + (startTime, endTime) = (endTime, startTime); + (startValue, endValue) = (endValue, startValue); + } commands.Add(new TypedCommand { Easing = easing, StartTime = startTime, EndTime = endTime, StartValue = startValue, EndValue = endValue }); diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index ced72aa5932a..0e7bb7216237 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -110,6 +110,8 @@ protected class TestEditor : Editor public new void Paste() => base.Paste(); + public new void Clone() => base.Clone(); + public new void SwitchToDifficulty(BeatmapInfo beatmapInfo) => base.SwitchToDifficulty(beatmapInfo); public new void CreateNewDifficulty(RulesetInfo rulesetInfo) => base.CreateNewDifficulty(rulesetInfo); diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index e47d19fba683..3ca83a478134 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -17,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays; @@ -42,6 +43,8 @@ public abstract class OsuGameTestScene : OsuManualInputManagerTestScene protected override bool CreateNestedActionContainer => false; + protected override bool DisplayCursorForManualInput => false; + [BackgroundDependencyLoader] private void load() { @@ -119,6 +122,8 @@ public class TestOsuGame : OsuGame public RealmAccess Realm => Dependencies.Get(); + public new GlobalCursorDisplay GlobalCursorDisplay => base.GlobalCursorDisplay; + public new BackButton BackButton => base.BackButton; public new BeatmapManager BeatmapManager => base.BeatmapManager; diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 9082ca9c58ad..e56c546bac7a 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -36,21 +36,31 @@ public abstract class OsuManualInputManagerTestScene : OsuTestScene /// protected virtual bool CreateNestedActionContainer => true; + /// + /// Whether a menu cursor controlled by the manual input manager should be displayed. + /// True by default, but is disabled for s as they provide their own global cursor. + /// + protected virtual bool DisplayCursorForManualInput => true; + protected OsuManualInputManagerTestScene() { - GlobalCursorDisplay cursorDisplay; + var mainContent = content = new Container { RelativeSizeAxes = Axes.Both }; - CompositeDrawable mainContent = cursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }; - - cursorDisplay.Child = content = new OsuTooltipContainer(cursorDisplay.MenuCursor) + if (DisplayCursorForManualInput) { - RelativeSizeAxes = Axes.Both - }; + var cursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }; + + cursorDisplay.Add(new OsuTooltipContainer(cursorDisplay.MenuCursor) + { + RelativeSizeAxes = Axes.Both, + Child = mainContent + }); + + mainContent = cursorDisplay; + } if (CreateNestedActionContainer) - { mainContent = new GlobalActionContainer(null).WithChild(mainContent); - } base.Content.AddRange(new Drawable[] { diff --git a/osu.Game/Tests/Visual/ScrollingTestContainer.cs b/osu.Game/Tests/Visual/ScrollingTestContainer.cs index cf7fe6e45d63..1817a704b9cc 100644 --- a/osu.Game/Tests/Visual/ScrollingTestContainer.cs +++ b/osu.Game/Tests/Visual/ScrollingTestContainer.cs @@ -99,8 +99,8 @@ public double GetDisplayStartTime(double originTime, float offset, double timeRa public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) => implementation.GetLength(startTime, endTime, timeRange, scrollLength); - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => implementation.PositionAt(time, currentTime, timeRange, scrollLength); + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) + => implementation.PositionAt(time, currentTime, timeRange, scrollLength, originTime); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) => implementation.TimeAt(position, currentTime, timeRange, scrollLength); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index c4e2a5168eb4..22474c059270 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -20,13 +20,13 @@ - + - - - - + + + + @@ -34,13 +34,13 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + diff --git a/osu.iOS.props b/osu.iOS.props index 622efcd63dd2..cf70b6557818 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,8 +61,8 @@ - - + + @@ -82,11 +82,11 @@ - - + + - +