diff --git a/global.json b/global.json index 10b61047ac..2c93a533e4 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,6 @@ "version": "3.1.100" }, "msbuild-sdks": { - "Microsoft.Build.Traversal": "2.2.3" + "Microsoft.Build.Traversal": "3.0.2" } } \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index 6e3d5eec1f..9a3d42d6b7 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index b17611f23f..62d8c17058 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -59,7 +59,7 @@ namespace osu.Desktop try { using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) - stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString()?.Split('"')[1].Replace("osu!.exe", ""); + stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); if (checkExists(stableInstallPath)) return stableInstallPath; @@ -138,8 +138,8 @@ namespace osu.Desktop break; // SDL2 DesktopWindow - case DesktopWindow desktopWindow: - desktopWindow.CursorState.Value |= CursorState.Hidden; + case SDL2DesktopWindow desktopWindow: + desktopWindow.CursorState |= CursorState.Hidden; desktopWindow.SetIconFromStream(iconStream); desktopWindow.Title = Name; desktopWindow.DragDrop += f => fileDrop(new[] { f }); diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 285a813d97..6ca7079654 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -22,9 +22,9 @@ namespace osu.Desktop { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; - bool useSdl = args.Contains("--sdl"); + bool useOsuTK = args.Contains("--tk"); - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true, useSdl: useSdl)) + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true, useOsuTK: useOsuTK)) { host.ExceptionThrown += handleException; diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 62e8f7c518..adf9c452f6 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,12 +24,12 @@ - + - - + + diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs index 1eb0975010..c01aff0aa0 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs @@ -38,17 +38,17 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods new Fruit { X = 0, - StartTime = 250 + StartTime = 1000 }, new Fruit { X = CatchPlayfield.WIDTH, - StartTime = 500 + StartTime = 2000 }, new JuiceStream { X = CatchPlayfield.CENTER_X, - StartTime = 750, + StartTime = 3000, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }) } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index 3c636a5b97..f552c3c27b 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Catch.Tests NewCombo = i % 8 == 0, Samples = new List(new[] { - new HitSampleInfo { Bank = "normal", Name = "hitnormal", Volume = 100 } + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "normal", volume: 100) }) }); } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs new file mode 100644 index 0000000000..64695153b5 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class TestSceneCatchPlayerLegacySkin : LegacySkinPlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 6eeda2c731..194a12a9b7 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -1,26 +1,194 @@ // 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.Allocation; using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcher : CatchSkinnableTestScene + public class TestSceneCatcher : OsuTestScene { - [BackgroundDependencyLoader] - private void load() + [Resolved] + private OsuConfigManager config { get; set; } + + private Container droppedObjectContainer; + + private TestCatcher catcher; + + [SetUp] + public void SetUp() => Schedule(() => { - SetContents(() => new Catcher(new Container()) + var difficulty = new BeatmapDifficulty + { + CircleSize = 0, + }; + + var trailContainer = new Container(); + droppedObjectContainer = new Container(); + catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty); + + Child = new Container { - RelativePositionAxes = Axes.None, Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Children = new Drawable[] + { + trailContainer, + droppedObjectContainer, + catcher + } + }; + }); + + [Test] + public void TestCatcherCatchWidth() + { + var halfWidth = Catcher.CalculateCatchWidth(new BeatmapDifficulty { CircleSize = 0 }) / 2; + AddStep("catch fruit", () => + { + attemptCatch(new Fruit { X = -halfWidth + 1 }); + attemptCatch(new Fruit { X = halfWidth - 1 }); }); + checkPlate(2); + AddStep("miss fruit", () => + { + attemptCatch(new Fruit { X = -halfWidth - 1 }); + attemptCatch(new Fruit { X = halfWidth + 1 }); + }); + checkPlate(2); + } + + [Test] + public void TestFruitChangesCatcherState() + { + AddStep("miss fruit", () => attemptCatch(new Fruit { X = 100 })); + checkState(CatcherAnimationState.Fail); + AddStep("catch fruit", () => attemptCatch(new Fruit())); + checkState(CatcherAnimationState.Idle); + AddStep("catch kiai fruit", () => attemptCatch(new TestKiaiFruit())); + checkState(CatcherAnimationState.Kiai); + } + + [Test] + public void TestNormalFruitResetsHyperDashState() + { + AddStep("catch hyper fruit", () => attemptCatch(new Fruit + { + HyperDashTarget = new Fruit { X = 100 } + })); + checkHyperDash(true); + AddStep("catch normal fruit", () => attemptCatch(new Fruit())); + checkHyperDash(false); + } + + [Test] + public void TestTinyDropletMissPreservesCatcherState() + { + AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit + { + HyperDashTarget = new Fruit { X = 100 } + })); + AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); + AddStep("miss tiny droplet", () => attemptCatch(new TinyDroplet { X = 100 })); + // catcher state and hyper dash state is preserved + checkState(CatcherAnimationState.Kiai); + checkHyperDash(true); + } + + [Test] + public void TestBananaMissPreservesCatcherState() + { + AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit + { + HyperDashTarget = new Fruit { X = 100 } + })); + AddStep("miss banana", () => attemptCatch(new Banana { X = 100 })); + // catcher state is preserved but hyper dash state is reset + checkState(CatcherAnimationState.Kiai); + checkHyperDash(false); + } + + [Test] + public void TestCatcherStacking() + { + AddStep("catch fruit", () => attemptCatch(new Fruit())); + checkPlate(1); + AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + checkPlate(10); + AddAssert("caught objects are stacked", () => + catcher.CaughtObjects.All(obj => obj.Y <= 0) && + catcher.CaughtObjects.Any(obj => obj.Y == 0) && + catcher.CaughtObjects.Any(obj => obj.Y < -20)); + } + + [Test] + public void TestCatcherExplosionAndDropping() + { + AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); + AddAssert("tiny droplet is exploded", () => catcher.CaughtObjects.Count() == 1 && droppedObjectContainer.Count == 1); + AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); + AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + AddStep("explode", () => catcher.Explode()); + AddAssert("fruits are exploded", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); + AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); + AddStep("catch fruits", () => attemptCatch(new Fruit(), 10)); + AddStep("drop", () => catcher.Drop()); + AddAssert("fruits are dropped", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); + } + + [TestCase(true)] + [TestCase(false)] + public void TestHitLighting(bool enabled) + { + AddStep($"{(enabled ? "enable" : "disable")} hit lighting", () => config.Set(OsuSetting.HitLighting, enabled)); + AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddAssert("check hit lighting", () => catcher.ChildrenOfType().Any() == enabled); + } + + private void checkPlate(int count) => AddAssert($"{count} objects on the plate", () => catcher.CaughtObjects.Count() == count); + + private void checkState(CatcherAnimationState state) => AddAssert($"catcher state is {state}", () => catcher.CurrentState == state); + + private void checkHyperDash(bool state) => AddAssert($"catcher is {(state ? "" : "not ")}hyper dashing", () => catcher.HyperDashing == state); + + private void attemptCatch(CatchHitObject hitObject, int count = 1) + { + hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + for (var i = 0; i < count; i++) + catcher.AttemptCatch(hitObject); + } + + public class TestCatcher : Catcher + { + public IEnumerable CaughtObjects => this.ChildrenOfType(); + + public TestCatcher(Container trailsTarget, Container droppedObjectTarget, BeatmapDifficulty difficulty) + : base(trailsTarget, droppedObjectTarget, difficulty) + { + } + } + + public class TestKiaiFruit : Fruit + { + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + } } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index e055f08dc2..281ddc7eaa 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -6,18 +6,17 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; -using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch.Tests { @@ -29,82 +28,68 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - private Catcher catcher => this.ChildrenOfType().First().MovableCatcher; + private Catcher catcher => this.ChildrenOfType().First(); + + private float circleSize; public TestSceneCatcherArea() { - AddSliderStep("CircleSize", 0, 8, 5, createCatcher); - AddToggleStep("Hyperdash", t => - CreatedDrawables.OfType().Select(i => i.Child) - .OfType().ForEach(c => c.ToggleHyperDash(t))); + AddSliderStep("circle size", 0, 8, 5, createCatcher); + AddToggleStep("hyper dash", t => this.ChildrenOfType().ForEach(area => area.ToggleHyperDash(t))); - AddRepeatStep("catch fruit", () => catchFruit(new TestFruit(false) - { - X = catcher.X - }), 20); - AddRepeatStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) - { - X = catcher.X, - LastInCombo = true, - }), 20); - AddRepeatStep("catch kiai fruit", () => catchFruit(new TestFruit(true) - { - X = catcher.X - }), 20); - AddRepeatStep("miss fruit", () => catchFruit(new Fruit - { - X = catcher.X + 100, - LastInCombo = true, - }, true), 20); + AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddStep("catch fruit last in combo", () => attemptCatch(new Fruit { LastInCombo = true })); + AddStep("catch kiai fruit", () => attemptCatch(new TestSceneCatcher.TestKiaiFruit())); + AddStep("miss last in combo", () => attemptCatch(new Fruit { X = 100, LastInCombo = true })); } - [TestCase(true)] - [TestCase(false)] - public void TestHitLighting(bool enable) + private void attemptCatch(Fruit fruit) { - AddStep("create catcher", () => createCatcher(5)); - - AddStep("toggle hit lighting", () => config.Set(OsuSetting.HitLighting, enable)); - AddStep("catch fruit", () => catchFruit(new TestFruit(false) + fruit.X += catcher.X; + fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { - X = catcher.X - })); - AddStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) - { - X = catcher.X, - LastInCombo = true - })); - AddAssert("check hit explosion", () => catcher.ChildrenOfType().Any() == enable); - } + CircleSize = circleSize + }); - private void catchFruit(Fruit fruit, bool miss = false) - { - this.ChildrenOfType().ForEach(area => + foreach (var area in this.ChildrenOfType()) { DrawableFruit drawable = new DrawableFruit(fruit); area.Add(drawable); Schedule(() => { - area.AttemptCatch(fruit); - area.OnNewResult(drawable, new JudgementResult(fruit, new CatchJudgement()) { Type = miss ? HitResult.Miss : HitResult.Great }); + bool caught = area.AttemptCatch(fruit); + area.OnNewResult(drawable, new JudgementResult(fruit, new CatchJudgement()) + { + Type = caught ? HitResult.Great : HitResult.Miss + }); drawable.Expire(); }); - }); + } } private void createCatcher(float size) { - SetContents(() => new CatchInputManager(catchRuleset) + circleSize = size; + + SetContents(() => { - RelativeSizeAxes = Axes.Both, - Child = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) + var droppedObjectContainer = new Container(); + + return new CatchInputManager(catchRuleset) { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - CreateDrawableRepresentation = ((DrawableRuleset)catchRuleset.CreateInstance().CreateDrawableRulesetWith(new CatchBeatmap())).CreateDrawableRepresentation - }, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + droppedObjectContainer, + new TestCatcherArea(droppedObjectContainer, new BeatmapDifficulty { CircleSize = size }) + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + } + } + }; }); } @@ -114,26 +99,13 @@ namespace osu.Game.Rulesets.Catch.Tests catchRuleset = rulesets.GetRuleset(2); } - public class TestFruit : Fruit - { - public TestFruit(bool kiai) - { - var kiaiCpi = new ControlPointInfo(); - kiaiCpi.Add(0, new EffectControlPoint { KiaiMode = kiai }); - - ApplyDefaultsToSelf(kiaiCpi, new BeatmapDifficulty()); - } - } - private class TestCatcherArea : CatcherArea { - public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) - : base(beatmapDifficulty) + public TestCatcherArea(Container droppedObjectContainer, BeatmapDifficulty beatmapDifficulty) + : base(droppedObjectContainer, beatmapDifficulty) { } - public new Catcher MovableCatcher => base.MovableCatcher; - public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index d35f828e28..3e4995482d 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -136,7 +136,7 @@ namespace osu.Game.Rulesets.Catch.Tests if (juice.NestedHitObjects.Last() is CatchHitObject tail) tail.LastInCombo = true; // usually the (Catch)BeatmapProcessor would do this for us when necessary - addToPlayfield(new DrawableJuiceStream(juice, drawableRuleset.CreateDrawableRepresentation)); + addToPlayfield(new DrawableJuiceStream(juice)); } private void spawnBananas(bool hit = false) diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 385d8ed7fa..3a651605d3 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; -using osuTK; namespace osu.Game.Rulesets.Catch.Tests { @@ -17,99 +19,69 @@ namespace osu.Game.Rulesets.Catch.Tests { base.LoadComplete(); - foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) - AddStep($"show {rep}", () => SetContents(() => createDrawable(rep))); + AddStep("show pear", () => SetContents(() => createDrawableFruit(0))); + AddStep("show grape", () => SetContents(() => createDrawableFruit(1))); + AddStep("show pineapple / apple", () => SetContents(() => createDrawableFruit(2))); + AddStep("show raspberry / orange", () => SetContents(() => createDrawableFruit(3))); + + AddStep("show banana", () => SetContents(createDrawableBanana)); AddStep("show droplet", () => SetContents(() => createDrawableDroplet())); AddStep("show tiny droplet", () => SetContents(createDrawableTinyDroplet)); - foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) - AddStep($"show hyperdash {rep}", () => SetContents(() => createDrawable(rep, true))); + AddStep("show hyperdash pear", () => SetContents(() => createDrawableFruit(0, true))); + AddStep("show hyperdash grape", () => SetContents(() => createDrawableFruit(1, true))); + AddStep("show hyperdash pineapple / apple", () => SetContents(() => createDrawableFruit(2, true))); + AddStep("show hyperdash raspberry / orange", () => SetContents(() => createDrawableFruit(3, true))); AddStep("show hyperdash droplet", () => SetContents(() => createDrawableDroplet(true))); } - private Drawable createDrawableTinyDroplet() - { - var droplet = new TestCatchTinyDroplet + private Drawable createDrawableFruit(int indexInBeatmap, bool hyperdash = false) => + new TestDrawableCatchHitObjectSpecimen(new DrawableFruit(new Fruit { - Scale = 1.5f, + IndexInBeatmap = indexInBeatmap, + HyperDashBindable = { Value = hyperdash } + })); + + private Drawable createDrawableBanana() => + new TestDrawableCatchHitObjectSpecimen(new DrawableBanana(new Banana())); + + private Drawable createDrawableDroplet(bool hyperdash = false) => + new TestDrawableCatchHitObjectSpecimen(new DrawableDroplet(new Droplet + { + HyperDashBindable = { Value = hyperdash } + })); + + private Drawable createDrawableTinyDroplet() => new TestDrawableCatchHitObjectSpecimen(new DrawableTinyDroplet(new TinyDroplet())); + } + + public class TestDrawableCatchHitObjectSpecimen : CompositeDrawable + { + public readonly ManualClock ManualClock; + + public TestDrawableCatchHitObjectSpecimen(DrawableCatchHitObject d) + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + ManualClock = new ManualClock(); + Clock = new FramedClock(ManualClock); + + var hitObject = d.HitObject; + hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + hitObject.Scale = 1.5f; + hitObject.StartTime = 500; + + d.Anchor = Anchor.Centre; + d.HitObjectApplied += _ => + { + d.LifetimeStart = double.NegativeInfinity; + d.LifetimeEnd = double.PositiveInfinity; }; - return new DrawableTinyDroplet(droplet) - { - Anchor = Anchor.Centre, - RelativePositionAxes = Axes.None, - Position = Vector2.Zero, - Alpha = 1, - LifetimeStart = double.NegativeInfinity, - LifetimeEnd = double.PositiveInfinity, - }; - } - - private Drawable createDrawableDroplet(bool hyperdash = false) - { - var droplet = new TestCatchDroplet - { - Scale = 1.5f, - HyperDashTarget = hyperdash ? new Banana() : null - }; - - return new DrawableDroplet(droplet) - { - Anchor = Anchor.Centre, - RelativePositionAxes = Axes.None, - Position = Vector2.Zero, - Alpha = 1, - LifetimeStart = double.NegativeInfinity, - LifetimeEnd = double.PositiveInfinity, - }; - } - - private Drawable createDrawable(FruitVisualRepresentation rep, bool hyperdash = false) - { - Fruit fruit = new TestCatchFruit(rep) - { - Scale = 1.5f, - HyperDashTarget = hyperdash ? new Banana() : null - }; - - return new DrawableFruit(fruit) - { - Anchor = Anchor.Centre, - RelativePositionAxes = Axes.None, - Position = Vector2.Zero, - Alpha = 1, - LifetimeStart = double.NegativeInfinity, - LifetimeEnd = double.PositiveInfinity, - }; - } - - public class TestCatchFruit : Fruit - { - public TestCatchFruit(FruitVisualRepresentation rep) - { - VisualRepresentation = rep; - StartTime = 1000000000000; - } - - public override FruitVisualRepresentation VisualRepresentation { get; } - } - - public class TestCatchDroplet : Droplet - { - public TestCatchDroplet() - { - StartTime = 1000000000000; - } - } - - public class TestCatchTinyDroplet : TinyDroplet - { - public TestCatchTinyDroplet() - { - StartTime = 1000000000000; - } + InternalChild = d; } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs new file mode 100644 index 0000000000..2ffebb7de1 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs @@ -0,0 +1,96 @@ +// 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.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneFruitRandomness : OsuTestScene + { + private readonly TestDrawableFruit drawableFruit; + private readonly TestDrawableBanana drawableBanana; + + public TestSceneFruitRandomness() + { + drawableFruit = new TestDrawableFruit(new Fruit()); + drawableBanana = new TestDrawableBanana(new Banana()); + + Add(new TestDrawableCatchHitObjectSpecimen(drawableFruit) { X = -200 }); + Add(new TestDrawableCatchHitObjectSpecimen(drawableBanana)); + + AddSliderStep("start time", 500, 600, 0, x => + { + drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = x; + }); + } + + [Test] + public void TestFruitRandomness() + { + // Use values such that the banana colour changes (2/3 of the integers are okay) + const int initial_start_time = 500; + const int another_start_time = 501; + + float fruitRotation = 0; + float bananaRotation = 0; + float bananaScale = 0; + Color4 bananaColour = new Color4(); + + AddStep("Initialize start time", () => + { + drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = initial_start_time; + + fruitRotation = drawableFruit.InnerRotation; + bananaRotation = drawableBanana.InnerRotation; + bananaScale = drawableBanana.InnerScale; + bananaColour = drawableBanana.AccentColour.Value; + }); + + AddStep("change start time", () => + { + drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = another_start_time; + }); + + AddAssert("fruit rotation is changed", () => drawableFruit.InnerRotation != fruitRotation); + AddAssert("banana rotation is changed", () => drawableBanana.InnerRotation != bananaRotation); + AddAssert("banana scale is changed", () => drawableBanana.InnerScale != bananaScale); + AddAssert("banana colour is changed", () => drawableBanana.AccentColour.Value != bananaColour); + + AddStep("reset start time", () => + { + drawableFruit.HitObject.StartTime = drawableBanana.HitObject.StartTime = initial_start_time; + }); + + AddAssert("rotation and scale restored", () => + drawableFruit.InnerRotation == fruitRotation && + drawableBanana.InnerRotation == bananaRotation && + drawableBanana.InnerScale == bananaScale && + drawableBanana.AccentColour.Value == bananaColour); + } + + private class TestDrawableFruit : DrawableFruit + { + public float InnerRotation => ScaleContainer.Rotation; + + public TestDrawableFruit(Fruit h) + : base(h) + { + } + } + + private class TestDrawableBanana : DrawableBanana + { + public float InnerRotation => ScaleContainer.Rotation; + public float InnerScale => ScaleContainer.Scale.X; + + public TestDrawableBanana(Banana h) + : base(h) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs new file mode 100644 index 0000000000..125e0c674c --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.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 osu.Framework.Bindables; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneFruitVisualChange : TestSceneFruitObjects + { + private readonly Bindable indexInBeatmap = new Bindable(); + private readonly Bindable hyperDash = new Bindable(); + + protected override void LoadComplete() + { + AddStep("fruit changes visual and hyper", () => SetContents(() => new TestDrawableCatchHitObjectSpecimen(new DrawableFruit(new Fruit + { + IndexInBeatmapBindable = { BindTarget = indexInBeatmap }, + HyperDashBindable = { BindTarget = hyperDash }, + })))); + + AddStep("droplet changes hyper", () => SetContents(() => new TestDrawableCatchHitObjectSpecimen(new DrawableDroplet(new Droplet + { + HyperDashBindable = { BindTarget = hyperDash }, + })))); + + Scheduler.AddDelayed(() => indexInBeatmap.Value++, 250, true); + Scheduler.AddDelayed(() => hyperDash.Value = !hyperDash.Value, 1000, true); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 1b8368794c..07cb73e5ff 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -117,7 +117,7 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new CatcherArea + Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, 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 dfe3bf8af4..61ecd79e3d 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 @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs index a08c5b6fb1..00ce9ea8c2 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -5,11 +5,11 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.MathUtils; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Catch.MathUtils; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Beatmaps { @@ -192,24 +192,24 @@ namespace osu.Game.Rulesets.Catch.Beatmaps private static void initialiseHyperDash(IBeatmap beatmap) { - List objectWithDroplets = new List(); + List palpableObjects = new List(); foreach (var currentObject in beatmap.HitObjects) { if (currentObject is Fruit fruitObject) - objectWithDroplets.Add(fruitObject); + palpableObjects.Add(fruitObject); if (currentObject is JuiceStream) { - foreach (var currentJuiceElement in currentObject.NestedHitObjects) + foreach (var juice in currentObject.NestedHitObjects) { - if (!(currentJuiceElement is TinyDroplet)) - objectWithDroplets.Add((CatchHitObject)currentJuiceElement); + if (juice is PalpableCatchHitObject palpableObject && !(juice is TinyDroplet)) + palpableObjects.Add(palpableObject); } } } - objectWithDroplets.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); + palpableObjects.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); double halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) / 2; @@ -221,10 +221,10 @@ namespace osu.Game.Rulesets.Catch.Beatmaps int lastDirection = 0; double lastExcess = halfCatcherWidth; - for (int i = 0; i < objectWithDroplets.Count - 1; i++) + for (int i = 0; i < palpableObjects.Count - 1; i++) { - CatchHitObject currentObject = objectWithDroplets[i]; - CatchHitObject nextObject = objectWithDroplets[i + 1]; + var currentObject = palpableObjects[i]; + var nextObject = palpableObjects[i + 1]; // Reset variables in-case values have changed (e.g. after applying HR) currentObject.HyperDashTarget = null; diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 3e21b8fbaf..dcd410e08f 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -12,9 +12,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing { private const float normalized_hitobject_radius = 41.0f; - public new CatchHitObject BaseObject => (CatchHitObject)base.BaseObject; + public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject; - public new CatchHitObject LastObject => (CatchHitObject)base.LastObject; + public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject; public readonly float NormalizedPosition; public readonly float LastNormalizedPosition; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs index a82d0af102..16ef56d845 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModEasy : ModEasy + public class CatchModEasy : ModEasyWithExtraLives { public override string Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; } diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 4ecfb7b16d..3f71da713e 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -1,22 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + +using System; using System.Collections.Generic; using osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Utils; +using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects { - public class Banana : Fruit + public class Banana : Fruit, IHasComboInformation { /// /// Index of banana in current shower. /// public int BananaIndex; - public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; - public override Judgement CreateJudgement() => new CatchBananaJudgement(); private static readonly List samples = new List { new BananaHitSampleInfo() }; @@ -26,11 +30,45 @@ namespace osu.Game.Rulesets.Catch.Objects Samples = samples; } - private class BananaHitSampleInfo : HitSampleInfo - { - private static string[] lookupNames { get; } = { "metronomelow", "catch-banana" }; + // override any external colour changes with banananana + Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => getBananaColour(); - public override IEnumerable LookupNames => lookupNames; + private Color4 getBananaColour() + { + switch (StatelessRNG.NextInt(3, RandomSeed)) + { + default: + return new Color4(255, 240, 0, 255); + + case 1: + return new Color4(255, 192, 0, 255); + + case 2: + return new Color4(214, 221, 28, 255); + } + } + + private class BananaHitSampleInfo : HitSampleInfo, IEquatable + { + private static readonly string[] lookup_names = { "Gameplay/metronomelow", "Gameplay/catch-banana" }; + + public override IEnumerable LookupNames => lookup_names; + + public BananaHitSampleInfo(int volume = 0) + : base(string.Empty, volume: volume) + { + } + + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + => new BananaHitSampleInfo(newVolume.GetOr(Volume)); + + public bool Equals(BananaHitSampleInfo? other) + => other != null; + + public override bool Equals(object? obj) + => obj is BananaHitSampleInfo other && Equals(other); + + public override int GetHashCode() => lookup_names.GetHashCode(); } } } diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index 89c51459a6..b45f95a8e6 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs @@ -9,8 +9,6 @@ namespace osu.Game.Rulesets.Catch.Objects { public class BananaShower : CatchHitObject, IHasDuration { - public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; - public override bool LastInCombo => true; public override Judgement CreateJudgement() => new IgnoreJudgement(); diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index 5985ec9b68..b86b3a7496 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -16,32 +16,47 @@ namespace osu.Game.Rulesets.Catch.Objects { public const float OBJECT_RADIUS = 64; - private float x; + // This value is after XOffset applied. + public readonly Bindable XBindable = new Bindable(); + + // This value is before XOffset applied. + private float originalX; /// /// The horizontal position of the fruit between 0 and . /// public float X { - get => x + XOffset; - set => x = value; + // TODO: I don't like this asymmetry. + get => XBindable.Value; + // originalX is set by `XBindable.BindValueChanged` + set => XBindable.Value = value + xOffset; } - /// - /// Whether this object can be placed on the catcher's plate. - /// - public virtual bool CanBePlated => false; + private float xOffset; /// /// A random offset applied to , set by the . /// - internal float XOffset { get; set; } + internal float XOffset + { + get => xOffset; + set + { + xOffset = value; + XBindable.Value = originalX + xOffset; + } + } public double TimePreempt = 1000; - public int IndexInBeatmap { get; set; } + public readonly Bindable IndexInBeatmapBindable = new Bindable(); - public virtual FruitVisualRepresentation VisualRepresentation => (FruitVisualRepresentation)(IndexInBeatmap % 4); + public int IndexInBeatmap + { + get => IndexInBeatmapBindable.Value; + set => IndexInBeatmapBindable.Value = value; + } public virtual bool NewCombo { get; set; } @@ -63,13 +78,6 @@ namespace osu.Game.Rulesets.Catch.Objects set => ComboIndexBindable.Value = value; } - /// - /// Difference between the distance to the next object - /// and the distance that would have triggered a hyper dash. - /// A value close to 0 indicates a difficult jump (for difficulty calculation). - /// - public float DistanceToHyperDash { get; set; } - public Bindable LastInComboBindable { get; } = new Bindable(); /// @@ -81,17 +89,19 @@ namespace osu.Game.Rulesets.Catch.Objects set => LastInComboBindable.Value = value; } - public float Scale { get; set; } = 1; + public readonly Bindable ScaleBindable = new Bindable(1); + + public float Scale + { + get => ScaleBindable.Value; + set => ScaleBindable.Value = value; + } /// - /// Whether this fruit can initiate a hyperdash. + /// The seed value used for visual randomness such as fruit rotation. + /// The value is truncated to an integer. /// - public bool HyperDash => HyperDashTarget != null; - - /// - /// The target fruit if we are to initiate a hyperdash. - /// - public CatchHitObject HyperDashTarget; + public int RandomSeed => (int)StartTime; protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { @@ -103,22 +113,10 @@ namespace osu.Game.Rulesets.Catch.Objects } protected override HitWindows CreateHitWindows() => HitWindows.Empty; - } - /// - /// Represents a single object that can be caught by the catcher. - /// - public abstract class PalpableCatchHitObject : CatchHitObject - { - public override bool CanBePlated => true; - } - - public enum FruitVisualRepresentation - { - Pear, - Grape, - Pineapple, - Raspberry, - Banana // banananananannaanana + protected CatchHitObject() + { + XBindable.BindValueChanged(x => originalX = x.NewValue - xOffset); + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs index a865984d45..8e9d80106b 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs @@ -1,26 +1,31 @@ // 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 JetBrains.Annotations; using osu.Framework.Graphics; -using osu.Framework.Utils; -using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableBanana : DrawableFruit { - public DrawableBanana(Banana h) + protected override FruitVisualRepresentation GetVisualRepresentation(int indexInBeatmap) => FruitVisualRepresentation.Banana; + + public DrawableBanana() + : this(null) + { + } + + public DrawableBanana([CanBeNull] Banana h) : base(h) { } - private Color4? colour; - - protected override Color4 GetComboColour(IReadOnlyList comboColours) + protected override void LoadComplete() { - // override any external colour changes with banananana - return colour ??= getBananaColour(); + base.LoadComplete(); + + // start time affects the random seed which is used to determine the banana colour + StartTimeBindable.BindValueChanged(_ => UpdateComboColour()); } protected override void UpdateInitialTransforms() @@ -30,14 +35,14 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables const float end_scale = 0.6f; const float random_scale_range = 1.6f; - ScaleContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RNG.NextSingle())) + ScaleContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RandomSingle(3))) .Then().ScaleTo(HitObject.Scale * end_scale, HitObject.TimePreempt); - ScaleContainer.RotateTo(getRandomAngle()) + ScaleContainer.RotateTo(getRandomAngle(1)) .Then() - .RotateTo(getRandomAngle(), HitObject.TimePreempt); + .RotateTo(getRandomAngle(2), HitObject.TimePreempt); - float getRandomAngle() => 180 * (RNG.NextSingle() * 2 - 1); + float getRandomAngle(int series) => 180 * (RandomSingle(series) * 2 - 1); } public override void PlaySamples() @@ -46,20 +51,5 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables if (Samples != null) Samples.Frequency.Value = 0.77f + ((Banana)HitObject).BananaIndex * 0.006f; } - - private Color4 getBananaColour() - { - switch (RNG.Next(0, 3)) - { - default: - return new Color4(255, 240, 0, 255); - - case 1: - return new Color4(255, 192, 0, 255); - - case 2: - return new Color4(214, 221, 28, 255); - } - } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs index 4ce80aceb8..9b2f95e221 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs @@ -1,26 +1,27 @@ // 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 JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableBananaShower : DrawableCatchHitObject + public class DrawableBananaShower : DrawableCatchHitObject { - private readonly Func> createDrawableRepresentation; private readonly Container bananaContainer; - public DrawableBananaShower(BananaShower s, Func> createDrawableRepresentation = null) + public DrawableBananaShower() + : this(null) + { + } + + public DrawableBananaShower([CanBeNull] BananaShower s) : base(s) { - this.createDrawableRepresentation = createDrawableRepresentation; RelativeSizeAxes = Axes.X; Origin = Anchor.BottomLeft; - X = 0; AddInternal(bananaContainer = new Container { RelativeSizeAxes = Axes.Both }); } @@ -34,18 +35,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables protected override void ClearNestedHitObjects() { base.ClearNestedHitObjects(); - bananaContainer.Clear(); - } - - protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) - { - switch (hitObject) - { - case Banana banana: - return createDrawableRepresentation?.Invoke(banana)?.With(o => ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); - } - - return base.CreateNestedHitObject(hitObject); + bananaContainer.Clear(false); } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs index 7922510a49..6aa8ff439e 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs @@ -2,77 +2,48 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using osu.Framework.Allocation; +using JetBrains.Annotations; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Drawables; -using osuTK; -using osuTK.Graphics; +using osu.Game.Utils; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public abstract class PalpableDrawableCatchHitObject : DrawableCatchHitObject - where TObject : PalpableCatchHitObject - { - protected Container ScaleContainer { get; private set; } - - protected PalpableDrawableCatchHitObject(TObject hitObject) - : base(hitObject) - { - Origin = Anchor.Centre; - Size = new Vector2(CatchHitObject.OBJECT_RADIUS * 2); - Masking = false; - } - - [BackgroundDependencyLoader] - private void load() - { - AddRangeInternal(new Drawable[] - { - ScaleContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - } - }); - - ScaleContainer.Scale = new Vector2(HitObject.Scale); - } - - protected override Color4 GetComboColour(IReadOnlyList comboColours) => - comboColours[(HitObject.IndexInBeatmap + 1) % comboColours.Count]; - } - - public abstract class DrawableCatchHitObject : DrawableCatchHitObject - where TObject : CatchHitObject - { - public new TObject HitObject; - - protected DrawableCatchHitObject(TObject hitObject) - : base(hitObject) - { - HitObject = hitObject; - Anchor = Anchor.BottomLeft; - } - } - public abstract class DrawableCatchHitObject : DrawableHitObject { + public readonly Bindable XBindable = new Bindable(); + protected override double InitialLifetimeOffset => HitObject.TimePreempt; - public virtual bool StaysOnPlate => HitObject.CanBePlated; - - public float DisplayRadius => DrawSize.X / 2 * Scale.X * HitObject.Scale; - protected override float SamplePlaybackPosition => HitObject.X / CatchPlayfield.WIDTH; - protected DrawableCatchHitObject(CatchHitObject hitObject) + public int RandomSeed => HitObject?.RandomSeed ?? 0; + + protected DrawableCatchHitObject([CanBeNull] CatchHitObject hitObject) : base(hitObject) { - X = hitObject.X; + Anchor = Anchor.BottomLeft; + } + + /// + /// Get a random number in range [0,1) based on seed . + /// + public float RandomSingle(int series) => StatelessRNG.NextSingle(RandomSeed, series); + + protected override void OnApply() + { + base.OnApply(); + + XBindable.BindTo(HitObject.XBindable); + } + + protected override void OnFree() + { + base.OnFree(); + + XBindable.UnbindFrom(HitObject.XBindable); } public Func CheckPosition; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs index 688240fd86..b8acea625b 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs @@ -1,18 +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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableDroplet : PalpableDrawableCatchHitObject + public class DrawableDroplet : DrawablePalpableCatchHitObject { public override bool StaysOnPlate => false; - public DrawableDroplet(Droplet h) + public DrawableDroplet() + : this(null) + { + } + + public DrawableDroplet([CanBeNull] CatchHitObject h) : base(h) { } @@ -20,7 +26,17 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables [BackgroundDependencyLoader] private void load() { - ScaleContainer.Child = new SkinnableDrawable(new CatchSkinComponent(CatchSkinComponents.Droplet), _ => new DropletPiece()); + HyperDash.BindValueChanged(_ => updatePiece(), true); + } + + private void updatePiece() + { + ScaleContainer.Child = new SkinnableDrawable( + new CatchSkinComponent(CatchSkinComponents.Droplet), + _ => new DropletPiece + { + HyperDash = { BindTarget = HyperDash } + }); } protected override void UpdateInitialTransforms() @@ -28,7 +44,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables base.UpdateInitialTransforms(); // roughly matches osu-stable - float startRotation = RNG.NextSingle() * 20; + float startRotation = RandomSingle(1) * 20; double duration = HitObject.TimePreempt + 2000; ScaleContainer.RotateTo(startRotation).RotateTo(startRotation + 720, duration); diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs index c1c34e4157..ef9df02a68 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs @@ -2,15 +2,27 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Utils; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableFruit : PalpableDrawableCatchHitObject + public class DrawableFruit : DrawablePalpableCatchHitObject { - public DrawableFruit(Fruit h) + public readonly Bindable VisualRepresentation = new Bindable(); + + protected virtual FruitVisualRepresentation GetVisualRepresentation(int indexInBeatmap) => (FruitVisualRepresentation)(indexInBeatmap % 4); + + public DrawableFruit() + : this(null) + { + } + + public DrawableFruit([CanBeNull] Fruit h) : base(h) { } @@ -18,10 +30,31 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables [BackgroundDependencyLoader] private void load() { - ScaleContainer.Child = new SkinnableDrawable( - new CatchSkinComponent(getComponent(HitObject.VisualRepresentation)), _ => new FruitPiece()); + IndexInBeatmap.BindValueChanged(change => + { + VisualRepresentation.Value = GetVisualRepresentation(change.NewValue); + }, true); - ScaleContainer.Rotation = (float)(RNG.NextDouble() - 0.5f) * 40; + VisualRepresentation.BindValueChanged(_ => updatePiece()); + HyperDash.BindValueChanged(_ => updatePiece(), true); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + ScaleContainer.RotateTo((RandomSingle(1) - 0.5f) * 40); + } + + private void updatePiece() + { + ScaleContainer.Child = new SkinnableDrawable( + new CatchSkinComponent(getComponent(VisualRepresentation.Value)), + _ => new FruitPiece + { + VisualRepresentation = { BindTarget = VisualRepresentation }, + HyperDash = { BindTarget = HyperDash }, + }); } private CatchSkinComponents getComponent(FruitVisualRepresentation hitObjectVisualRepresentation) @@ -48,4 +81,13 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables } } } + + public enum FruitVisualRepresentation + { + Pear, + Grape, + Pineapple, + Raspberry, + Banana // banananananannaanana + } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs index 7bc016d94f..a496a35842 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs @@ -1,37 +1,33 @@ // 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 JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osuTK; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableJuiceStream : DrawableCatchHitObject + public class DrawableJuiceStream : DrawableCatchHitObject { - private readonly Func> createDrawableRepresentation; private readonly Container dropletContainer; - public override Vector2 OriginPosition => base.OriginPosition - new Vector2(0, CatchHitObject.OBJECT_RADIUS); + public DrawableJuiceStream() + : this(null) + { + } - public DrawableJuiceStream(JuiceStream s, Func> createDrawableRepresentation = null) + public DrawableJuiceStream([CanBeNull] JuiceStream s) : base(s) { - this.createDrawableRepresentation = createDrawableRepresentation; RelativeSizeAxes = Axes.X; Origin = Anchor.BottomLeft; - X = 0; AddInternal(dropletContainer = new Container { RelativeSizeAxes = Axes.Both, }); } protected override void AddNestedHitObject(DrawableHitObject hitObject) { - hitObject.Origin = Anchor.BottomCentre; - base.AddNestedHitObject(hitObject); dropletContainer.Add(hitObject); } @@ -39,19 +35,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables protected override void ClearNestedHitObjects() { base.ClearNestedHitObjects(); - dropletContainer.Clear(); - } - - protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) - { - switch (hitObject) - { - case CatchHitObject catchObject: - return createDrawableRepresentation?.Invoke(catchObject)?.With(o => - ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); - } - - throw new ArgumentException($"{nameof(hitObject)} must be of type {nameof(CatchHitObject)}."); + dropletContainer.Clear(false); } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs new file mode 100644 index 0000000000..0877b5e248 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public abstract class DrawablePalpableCatchHitObject : DrawableCatchHitObject + { + public new PalpableCatchHitObject HitObject => (PalpableCatchHitObject)base.HitObject; + + public readonly Bindable HyperDash = new Bindable(); + + public readonly Bindable ScaleBindable = new Bindable(1); + + public readonly Bindable IndexInBeatmap = new Bindable(); + + /// + /// The multiplicative factor applied to scale relative to scale. + /// + protected virtual float ScaleFactor => 1; + + /// + /// Whether this hit object should stay on the catcher plate when the object is caught by the catcher. + /// + public virtual bool StaysOnPlate => true; + + public float DisplayRadius => CatchHitObject.OBJECT_RADIUS * HitObject.Scale * ScaleFactor; + + protected readonly Container ScaleContainer; + + protected DrawablePalpableCatchHitObject([CanBeNull] CatchHitObject h) + : base(h) + { + Origin = Anchor.Centre; + Size = new Vector2(CatchHitObject.OBJECT_RADIUS * 2); + + AddInternal(ScaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + }); + } + + [BackgroundDependencyLoader] + private void load() + { + XBindable.BindValueChanged(x => + { + if (!IsOnPlate) X = x.NewValue; + }, true); + + ScaleBindable.BindValueChanged(scale => + { + ScaleContainer.Scale = new Vector2(scale.NewValue * ScaleFactor); + }, true); + + IndexInBeatmap.BindValueChanged(_ => UpdateComboColour()); + } + + protected override void OnApply() + { + base.OnApply(); + + HyperDash.BindTo(HitObject.HyperDashBindable); + ScaleBindable.BindTo(HitObject.ScaleBindable); + IndexInBeatmap.BindTo(HitObject.IndexInBeatmapBindable); + } + + protected override void OnFree() + { + HyperDash.UnbindFrom(HitObject.HyperDashBindable); + ScaleBindable.UnbindFrom(HitObject.ScaleBindable); + IndexInBeatmap.UnbindFrom(HitObject.IndexInBeatmapBindable); + + base.OnFree(); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs index ae775684d8..8f5a04dfda 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs @@ -1,21 +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 osu.Framework.Allocation; +using JetBrains.Annotations; namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableTinyDroplet : DrawableDroplet { - public DrawableTinyDroplet(TinyDroplet h) - : base(h) + protected override float ScaleFactor => base.ScaleFactor / 2; + + public DrawableTinyDroplet() + : this(null) { } - [BackgroundDependencyLoader] - private void load() + public DrawableTinyDroplet([CanBeNull] TinyDroplet h) + : base(h) { - ScaleContainer.Scale /= 2; } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DropletPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DropletPiece.cs deleted file mode 100644 index c2499446fa..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DropletPiece.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; -using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Objects.Drawables; -using osuTK; - -namespace osu.Game.Rulesets.Catch.Objects.Drawables -{ - public class DropletPiece : CompositeDrawable - { - public DropletPiece() - { - Size = new Vector2(CatchHitObject.OBJECT_RADIUS / 2); - } - - [BackgroundDependencyLoader] - private void load(DrawableHitObject drawableObject) - { - DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; - var hitObject = drawableCatchObject.HitObject; - - InternalChild = new Pulp - { - RelativeSizeAxes = Axes.Both, - AccentColour = { BindTarget = drawableObject.AccentColour } - }; - - if (hitObject.HyperDash) - { - AddInternal(new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(2f), - Depth = 1, - Children = new Drawable[] - { - new Circle - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR, - BorderThickness = 6, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, - Alpha = 0.3f, - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, - } - } - } - } - }); - } - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs deleted file mode 100644 index 4bffdab3d8..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Objects.Drawables; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Catch.Objects.Drawables -{ - internal class FruitPiece : CompositeDrawable - { - /// - /// Because we're adding a border around the fruit, we need to scale down some. - /// - public const float RADIUS_ADJUST = 1.1f; - - private Circle border; - private CatchHitObject hitObject; - - public FruitPiece() - { - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(DrawableHitObject drawableObject) - { - DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; - hitObject = drawableCatchObject.HitObject; - - AddRangeInternal(new[] - { - getFruitFor(drawableCatchObject.HitObject.VisualRepresentation), - border = new Circle - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - BorderColour = Color4.White, - BorderThickness = 6f * RADIUS_ADJUST, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, - Alpha = 0, - RelativeSizeAxes = Axes.Both - } - } - }, - }); - - if (hitObject.HyperDash) - { - AddInternal(new Circle - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR, - BorderThickness = 12f * RADIUS_ADJUST, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, - Alpha = 0.3f, - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, - } - } - }); - } - } - - protected override void Update() - { - base.Update(); - border.Alpha = (float)Math.Clamp((hitObject.StartTime - Time.Current) / 500, 0, 1); - } - - private Drawable getFruitFor(FruitVisualRepresentation representation) - { - switch (representation) - { - case FruitVisualRepresentation.Pear: - return new PearPiece(); - - case FruitVisualRepresentation.Grape: - return new GrapePiece(); - - case FruitVisualRepresentation.Pineapple: - return new PineapplePiece(); - - case FruitVisualRepresentation.Banana: - return new BananaPiece(); - - case FruitVisualRepresentation.Raspberry: - return new RaspberryPiece(); - } - - return Empty(); - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/BananaPiece.cs similarity index 88% rename from osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/BananaPiece.cs index ebb0bf0f2c..fa8837dec5 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/BananaPiece.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osuTK; -namespace osu.Game.Rulesets.Catch.Objects.Drawables +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public class BananaPiece : PulpFormation { diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/BorderPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/BorderPiece.cs new file mode 100644 index 0000000000..1e7a0b0685 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/BorderPiece.cs @@ -0,0 +1,31 @@ +// 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.Shapes; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces +{ + public class BorderPiece : Circle + { + public BorderPiece() + { + Size = new Vector2(CatchHitObject.OBJECT_RADIUS * 2); + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + BorderColour = Color4.White; + BorderThickness = 6f * FruitPiece.RADIUS_ADJUST; + + // Border is drawn only when there is a child drawable. + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }; + } + } +} + diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs new file mode 100644 index 0000000000..c90407ae15 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs @@ -0,0 +1,37 @@ +// 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.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces +{ + public class DropletPiece : CompositeDrawable + { + public readonly Bindable HyperDash = new Bindable(); + + public DropletPiece() + { + Size = new Vector2(CatchHitObject.OBJECT_RADIUS / 2); + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject) + { + InternalChild = new Pulp + { + RelativeSizeAxes = Axes.Both, + AccentColour = { BindTarget = drawableObject.AccentColour } + }; + + if (HyperDash.Value) + { + AddInternal(new HyperDropletBorderPiece()); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs new file mode 100644 index 0000000000..31487ee407 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces +{ + internal class FruitPiece : CompositeDrawable + { + /// + /// Because we're adding a border around the fruit, we need to scale down some. + /// + public const float RADIUS_ADJUST = 1.1f; + + public readonly Bindable VisualRepresentation = new Bindable(); + public readonly Bindable HyperDash = new Bindable(); + + [CanBeNull] + private DrawableCatchHitObject drawableHitObject; + + [CanBeNull] + private BorderPiece borderPiece; + + public FruitPiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(permitNulls: true)] + private void load([CanBeNull] DrawableHitObject drawable) + { + drawableHitObject = (DrawableCatchHitObject)drawable; + + AddInternal(getFruitFor(VisualRepresentation.Value)); + + // if it is not part of a DHO, the border is always invisible. + if (drawableHitObject != null) + AddInternal(borderPiece = new BorderPiece()); + + if (HyperDash.Value) + AddInternal(new HyperBorderPiece()); + } + + protected override void Update() + { + if (borderPiece != null && drawableHitObject?.HitObject != null) + borderPiece.Alpha = (float)Math.Clamp((drawableHitObject.HitObject.StartTime - Time.Current) / 500, 0, 1); + } + + private Drawable getFruitFor(FruitVisualRepresentation representation) + { + switch (representation) + { + case FruitVisualRepresentation.Pear: + return new PearPiece(); + + case FruitVisualRepresentation.Grape: + return new GrapePiece(); + + case FruitVisualRepresentation.Pineapple: + return new PineapplePiece(); + + case FruitVisualRepresentation.Banana: + return new BananaPiece(); + + case FruitVisualRepresentation.Raspberry: + return new RaspberryPiece(); + } + + return Empty(); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/GrapePiece.cs similarity index 92% rename from osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/GrapePiece.cs index 1d1faf893b..15349c18d5 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/GrapePiece.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osuTK; -namespace osu.Game.Rulesets.Catch.Objects.Drawables +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public class GrapePiece : PulpFormation { diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/HyperBorderPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/HyperBorderPiece.cs new file mode 100644 index 0000000000..60bb07e89d --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/HyperBorderPiece.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 osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.UI; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces +{ + public class HyperBorderPiece : BorderPiece + { + public HyperBorderPiece() + { + BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; + BorderThickness = 12f * FruitPiece.RADIUS_ADJUST; + + Child.Alpha = 0.3f; + Child.Blending = BlendingParameters.Additive; + Child.Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR; + } + } +} + diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/HyperDropletBorderPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/HyperDropletBorderPiece.cs new file mode 100644 index 0000000000..1bd9fd6bb2 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/HyperDropletBorderPiece.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces +{ + public class HyperDropletBorderPiece : HyperBorderPiece + { + public HyperDropletBorderPiece() + { + Size /= 2; + BorderThickness = 6f; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PearPiece.cs similarity index 92% rename from osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PearPiece.cs index 7f14217cda..3372a06996 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PearPiece.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osuTK; -namespace osu.Game.Rulesets.Catch.Objects.Drawables +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public class PearPiece : PulpFormation { diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PineapplePiece.cs similarity index 93% rename from osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PineapplePiece.cs index c328ba1837..7f80c58178 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PineapplePiece.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osuTK; -namespace osu.Game.Rulesets.Catch.Objects.Drawables +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public class PineapplePiece : PulpFormation { diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PulpFormation.cs similarity index 96% rename from osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PulpFormation.cs index be70c3400c..1df548e70a 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/PulpFormation.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Catch.Objects.Drawables +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public abstract class PulpFormation : CompositeDrawable { diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/RaspberryPiece.cs similarity index 93% rename from osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/RaspberryPiece.cs index 22ce3ba5b3..288ece95b2 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/RaspberryPiece.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osuTK; -namespace osu.Game.Rulesets.Catch.Objects.Drawables +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public class RaspberryPiece : PulpFormation { diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index e209d012fa..d5819935ad 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -50,12 +50,7 @@ namespace osu.Game.Rulesets.Catch.Objects { base.CreateNestedHitObjects(cancellationToken); - var dropletSamples = Samples.Select(s => new HitSampleInfo - { - Bank = s.Bank, - Name = @"slidertick", - Volume = s.Volume - }).ToList(); + var dropletSamples = Samples.Select(s => s.With(@"slidertick")).ToList(); int nodeIndex = 0; SliderEventDescriptor? lastEvent = null; diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs new file mode 100644 index 0000000000..0cd3af01df --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs @@ -0,0 +1,48 @@ +// 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 osu.Framework.Bindables; +using osu.Game.Rulesets.Objects.Types; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects +{ + /// + /// Represents a single object that can be caught by the catcher. + /// This includes normal fruits, droplets, and bananas but excludes objects that act only as a container of nested hit objects. + /// + public abstract class PalpableCatchHitObject : CatchHitObject, IHasComboInformation + { + /// + /// Difference between the distance to the next object + /// and the distance that would have triggered a hyper dash. + /// A value close to 0 indicates a difficult jump (for difficulty calculation). + /// + public float DistanceToHyperDash { get; set; } + + public readonly Bindable HyperDashBindable = new Bindable(); + + /// + /// Whether this fruit can initiate a hyperdash. + /// + public bool HyperDash => HyperDashBindable.Value; + + private CatchHitObject hyperDashTarget; + + /// + /// The target fruit if we are to initiate a hyperdash. + /// + public CatchHitObject HyperDashTarget + { + get => hyperDashTarget; + set + { + hyperDashTarget = value; + HyperDashBindable.Value = value != null; + } + } + + Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count]; + } +} diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index a4f54bfe82..dfc81ee8d9 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Catch.Replays float lastPosition = CatchPlayfield.CENTER_X; double lastTime = 0; - void moveToNext(CatchHitObject h) + void moveToNext(PalpableCatchHitObject h) { float positionChange = Math.Abs(lastPosition - h.X); double timeAvailable = h.StartTime - lastTime; @@ -101,23 +101,16 @@ namespace osu.Game.Rulesets.Catch.Replays foreach (var obj in Beatmap.HitObjects) { - switch (obj) + if (obj is PalpableCatchHitObject palpableObject) { - case Fruit _: - moveToNext(obj); - break; + moveToNext(palpableObject); } foreach (var nestedObj in obj.NestedHitObjects.Cast()) { - switch (nestedObj) + if (nestedObj is PalpableCatchHitObject palpableNestedObject) { - case Banana _: - case TinyDroplet _: - case Droplet _: - case Fruit _: - moveToNext(nestedObj); - break; + moveToNext(palpableNestedObject); } } } diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs index 381d066750..b8648f46f0 100644 --- a/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs @@ -19,7 +19,8 @@ namespace osu.Game.Rulesets.Catch.Skinning { private readonly string lookupName; - private readonly IBindable accentColour = new Bindable(); + private readonly Bindable accentColour = new Bindable(); + private readonly Bindable hyperDash = new Bindable(); private Sprite colouredSprite; public LegacyFruitPiece(string lookupName) @@ -31,9 +32,10 @@ namespace osu.Game.Rulesets.Catch.Skinning [BackgroundDependencyLoader] private void load(DrawableHitObject drawableObject, ISkinSource skin) { - DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + var drawableCatchObject = (DrawablePalpableCatchHitObject)drawableObject; accentColour.BindTo(drawableCatchObject.AccentColour); + hyperDash.BindTo(drawableCatchObject.HyperDash); InternalChildren = new Drawable[] { @@ -51,9 +53,9 @@ namespace osu.Game.Rulesets.Catch.Skinning }, }; - if (drawableCatchObject.HitObject.HyperDash) + if (hyperDash.Value) { - var hyperDash = new Sprite + var hyperDashOverlay = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -67,7 +69,7 @@ namespace osu.Game.Rulesets.Catch.Skinning Catcher.DEFAULT_HYPER_DASH_COLOUR, }; - AddInternal(hyperDash); + AddInternal(hyperDashOverlay); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 735d7fc300..df87359ed6 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; @@ -35,41 +36,53 @@ namespace osu.Game.Rulesets.Catch.UI public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation) { - var explodingFruitContainer = new Container + var droppedObjectContainer = new Container { RelativeSizeAxes = Axes.Both, }; - CatcherArea = new CatcherArea(difficulty) + CatcherArea = new CatcherArea(droppedObjectContainer, difficulty) { - CreateDrawableRepresentation = createDrawableRepresentation, - ExplodingFruitTarget = explodingFruitContainer, Anchor = Anchor.BottomLeft, Origin = Anchor.TopLeft, }; InternalChildren = new[] { - explodingFruitContainer, + droppedObjectContainer, CatcherArea.MovableCatcher.CreateProxiedContent(), HitObjectContainer, CatcherArea, }; } - public bool CheckIfWeCanCatch(CatchHitObject obj) => CatcherArea.AttemptCatch(obj); - - public override void Add(DrawableHitObject h) + [BackgroundDependencyLoader] + private void load() { - h.OnNewResult += onNewResult; - h.OnRevertResult += onRevertResult; - - base.Add(h); - - var fruit = (DrawableCatchHitObject)h; - fruit.CheckPosition = CheckIfWeCanCatch; + RegisterPool(50); + RegisterPool(50); + RegisterPool(100); + RegisterPool(100); + RegisterPool(10); + RegisterPool(2); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // these subscriptions need to be done post constructor to ensure externally bound components have a chance to populate required fields (ScoreProcessor / ComboAtJudgement in this case). + NewResult += onNewResult; + RevertResult += onRevertResult; + } + + protected override void OnNewDrawableHitObject(DrawableHitObject d) + { + ((DrawableCatchHitObject)d).CheckPosition = checkIfWeCanCatch; + } + + private bool checkIfWeCanCatch(CatchHitObject obj) => CatcherArea.AttemptCatch(obj); + private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) => CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result); diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index a221ca7966..2a3447c80a 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -16,7 +17,6 @@ using osu.Game.Configuration; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Skinning; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -46,19 +46,15 @@ namespace osu.Game.Rulesets.Catch.UI /// public const double BASE_SPEED = 1.0; - public Container ExplodingFruitTarget; - - private Container caughtFruitContainer { get; } = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }; - [NotNull] private readonly Container trailsTarget; private CatcherTrailDisplay trails; + private readonly Container droppedObjectTarget; + + private readonly Container caughtFruitContainer; + public CatcherAnimationState CurrentState { get; private set; } /// @@ -91,9 +87,9 @@ namespace osu.Game.Rulesets.Catch.UI /// private readonly float catchWidth; - private CatcherSprite catcherIdle; - private CatcherSprite catcherKiai; - private CatcherSprite catcherFail; + private readonly CatcherSprite catcherIdle; + private readonly CatcherSprite catcherKiai; + private readonly CatcherSprite catcherFail; private CatcherSprite currentCatcher; @@ -107,9 +103,13 @@ namespace osu.Game.Rulesets.Catch.UI private float hyperDashTargetPosition; private Bindable hitLighting; - public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) + private readonly DrawablePool hitExplosionPool; + private readonly Container hitExplosionContainer; + + public Catcher([NotNull] Container trailsTarget, [NotNull] Container droppedObjectTarget, BeatmapDifficulty difficulty = null) { this.trailsTarget = trailsTarget; + this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; @@ -118,16 +118,15 @@ namespace osu.Game.Rulesets.Catch.UI Scale = calculateScale(difficulty); catchWidth = CalculateCatchWidth(Scale); - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - hitLighting = config.GetBindable(OsuSetting.HitLighting); InternalChildren = new Drawable[] { - caughtFruitContainer, + hitExplosionPool = new DrawablePool(10), + caughtFruitContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }, catcherIdle = new CatcherSprite(CatcherAnimationState.Idle) { Anchor = Anchor.TopCentre, @@ -142,9 +141,19 @@ namespace osu.Game.Rulesets.Catch.UI { Anchor = Anchor.TopCentre, Alpha = 0, - } + }, + hitExplosionContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }, }; + } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + hitLighting = config.GetBindable(OsuSetting.HitLighting); trails = new CatcherTrailDisplay(this); updateCatcher(); @@ -166,65 +175,28 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Calculates the scale of the catcher based off the provided beatmap difficulty. /// - private static Vector2 calculateScale(BeatmapDifficulty difficulty) - => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); + private static Vector2 calculateScale(BeatmapDifficulty difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The scale of the catcher. - internal static float CalculateCatchWidth(Vector2 scale) - => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; + internal static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The beatmap difficulty. - internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) - => CalculateCatchWidth(calculateScale(difficulty)); - - /// - /// Add a caught fruit to the catcher's stack. - /// - /// The fruit that was caught. - public void PlaceOnPlate(DrawableCatchHitObject fruit) - { - var ourRadius = fruit.DisplayRadius; - float theirRadius = 0; - - const float allowance = 10; - - while (caughtFruitContainer.Any(f => - f.LifetimeEnd == double.MaxValue && - Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) - { - var diff = (ourRadius + theirRadius) / allowance; - fruit.X += (RNG.NextSingle() - 0.5f) * diff * 2; - fruit.Y -= RNG.NextSingle() * diff; - } - - fruit.X = Math.Clamp(fruit.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); - - caughtFruitContainer.Add(fruit); - - if (hitLighting.Value) - { - AddInternal(new HitExplosion(fruit) - { - X = fruit.X, - Scale = new Vector2(fruit.HitObject.Scale) - }); - } - } + internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); /// /// Let the catcher attempt to catch a fruit. /// - /// The fruit to catch. + /// The fruit to catch. /// Whether the catch is possible. - public bool AttemptCatch(CatchHitObject fruit) + public bool AttemptCatch(CatchHitObject hitObject) { - if (!fruit.CanBePlated) + if (!(hitObject is PalpableCatchHitObject fruit)) return false; var halfCatchWidth = catchWidth * 0.5f; @@ -237,7 +209,10 @@ namespace osu.Game.Rulesets.Catch.UI catchObjectPosition >= catcherPosition - halfCatchWidth && catchObjectPosition <= catcherPosition + halfCatchWidth; - // only update hyperdash state if we are not catching a tiny droplet. + if (validCatch) + placeCaughtObject(fruit); + + // droplet doesn't affect the catcher state if (fruit is TinyDroplet) return validCatch; if (validCatch && fruit.HyperDash) @@ -291,24 +266,17 @@ namespace osu.Game.Rulesets.Catch.UI } } - private void runHyperDashStateTransition(bool hyperDashing) + public void UpdatePosition(float position) { - updateTrailVisibility(); + position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); - if (hyperDashing) - { - this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } - else - { - this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } + if (position == X) + return; + + Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); + X = position; } - private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; - public bool OnPressed(CatchAction action) { switch (action) @@ -347,56 +315,34 @@ namespace osu.Game.Rulesets.Catch.UI } } - public void UpdatePosition(float position) - { - position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); - - if (position == X) - return; - - Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); - X = position; - } - /// /// Drop any fruit off the plate. /// - public void Drop() - { - foreach (var f in caughtFruitContainer.ToArray()) - Drop(f); - } + public void Drop() => clearPlate(DroppedObjectAnimation.Drop); /// - /// Explode any fruit off the plate. + /// Explode all fruit off the plate. /// - public void Explode() - { - foreach (var f in caughtFruitContainer.ToArray()) - Explode(f); - } + public void Explode() => clearPlate(DroppedObjectAnimation.Explode); - public void Drop(DrawableHitObject fruit) + private void runHyperDashStateTransition(bool hyperDashing) { - removeFromPlateWithTransform(fruit, f => + updateTrailVisibility(); + + if (hyperDashing) { - f.MoveToY(f.Y + 75, 750, Easing.InSine); - f.FadeOut(750); - }); - } - - public void Explode(DrawableHitObject fruit) - { - var originalX = fruit.X * Scale.X; - - removeFromPlateWithTransform(fruit, f => + this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } + else { - f.MoveToY(f.Y - 50, 250, Easing.OutSine).Then().MoveToY(f.Y + 50, 500, Easing.InSine); - f.MoveToX(f.X + originalX * 6, 1000); - f.FadeOut(750); - }); + this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } } + private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; + protected override void SkinChanged(ISkinSource skin, bool allowFallback) { base.SkinChanged(skin, allowFallback); @@ -469,33 +415,143 @@ namespace osu.Game.Rulesets.Catch.UI updateCatcher(); } - private void removeFromPlateWithTransform(DrawableHitObject fruit, Action action) + private void placeCaughtObject(PalpableCatchHitObject source) { - if (ExplodingFruitTarget != null) + var caughtObject = createCaughtObject(source); + + if (caughtObject == null) return; + + caughtObject.RelativePositionAxes = Axes.None; + caughtObject.X = source.X - X; + caughtObject.IsOnPlate = true; + + caughtObject.Anchor = Anchor.TopCentre; + caughtObject.Origin = Anchor.Centre; + caughtObject.Scale *= 0.5f; + caughtObject.LifetimeStart = source.StartTime; + caughtObject.LifetimeEnd = double.MaxValue; + + adjustPositionInStack(caughtObject); + + caughtFruitContainer.Add(caughtObject); + + addLighting(caughtObject); + + if (!caughtObject.StaysOnPlate) + removeFromPlate(caughtObject, DroppedObjectAnimation.Explode); + } + + private void adjustPositionInStack(DrawablePalpableCatchHitObject caughtObject) + { + const float radius_div_2 = CatchHitObject.OBJECT_RADIUS / 2; + const float allowance = 10; + + float caughtObjectRadius = caughtObject.DisplayRadius; + + while (caughtFruitContainer.Any(f => Vector2Extensions.Distance(f.Position, caughtObject.Position) < (caughtObjectRadius + radius_div_2) / (allowance / 2))) { - fruit.Anchor = Anchor.TopLeft; - fruit.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); + float diff = (caughtObjectRadius + radius_div_2) / allowance; - if (!caughtFruitContainer.Remove(fruit)) - // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). - // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. - return; - - ExplodingFruitTarget.Add(fruit); + caughtObject.X += (RNG.NextSingle() - 0.5f) * diff * 2; + caughtObject.Y -= RNG.NextSingle() * diff; } - var actionTime = Clock.CurrentTime; + caughtObject.X = Math.Clamp(caughtObject.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); + } - fruit.ApplyCustomUpdateState += onFruitOnApplyCustomUpdateState; - onFruitOnApplyCustomUpdateState(fruit, fruit.State.Value); + private void addLighting(DrawablePalpableCatchHitObject caughtObject) + { + if (!hitLighting.Value) return; - void onFruitOnApplyCustomUpdateState(DrawableHitObject o, ArmedState state) + HitExplosion hitExplosion = hitExplosionPool.Get(); + hitExplosion.X = caughtObject.X; + hitExplosion.Scale = new Vector2(caughtObject.HitObject.Scale); + hitExplosion.ObjectColour = caughtObject.AccentColour.Value; + hitExplosionContainer.Add(hitExplosion); + } + + private DrawablePalpableCatchHitObject createCaughtObject(PalpableCatchHitObject source) + { + switch (source) { - using (fruit.BeginAbsoluteSequence(actionTime)) - action(fruit); + case Banana banana: + return new DrawableBanana(banana); - fruit.Expire(); + case Fruit fruit: + return new DrawableFruit(fruit); + + case TinyDroplet tiny: + return new DrawableTinyDroplet(tiny); + + case Droplet droplet: + return new DrawableDroplet(droplet); + + default: + return null; } } + + private void clearPlate(DroppedObjectAnimation animation) + { + var caughtObjects = caughtFruitContainer.Children.ToArray(); + caughtFruitContainer.Clear(false); + + droppedObjectTarget.AddRange(caughtObjects); + + foreach (var caughtObject in caughtObjects) + drop(caughtObject, animation); + } + + private void removeFromPlate(DrawablePalpableCatchHitObject caughtObject, DroppedObjectAnimation animation) + { + if (!caughtFruitContainer.Remove(caughtObject)) + throw new InvalidOperationException("Can only drop a caught object on the plate"); + + droppedObjectTarget.Add(caughtObject); + + drop(caughtObject, animation); + } + + private void drop(DrawablePalpableCatchHitObject d, DroppedObjectAnimation animation) + { + var originalX = d.X * Scale.X; + var startTime = Clock.CurrentTime; + + d.Anchor = Anchor.TopLeft; + d.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(d.DrawPosition, droppedObjectTarget); + + // we cannot just apply the transforms because DHO clears transforms when state is updated + d.ApplyCustomUpdateState += (o, state) => animate(o, animation, originalX, startTime); + if (d.IsLoaded) + animate(d, animation, originalX, startTime); + } + + private void animate(Drawable d, DroppedObjectAnimation animation, float originalX, double startTime) + { + using (d.BeginAbsoluteSequence(startTime)) + { + switch (animation) + { + case DroppedObjectAnimation.Drop: + d.MoveToY(d.Y + 75, 750, Easing.InSine); + d.FadeOut(750); + break; + + case DroppedObjectAnimation.Explode: + d.MoveToY(d.Y - 50, 250, Easing.OutSine).Then().MoveToY(d.Y + 50, 500, Easing.InSine); + d.MoveToX(d.X + originalX * 6, 1000); + d.FadeOut(750); + break; + } + + d.Expire(); + } + } + + private enum DroppedObjectAnimation + { + Drop, + Explode + } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 5e794a76aa..539776354c 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; @@ -10,7 +9,6 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osuTK; @@ -21,19 +19,10 @@ namespace osu.Game.Rulesets.Catch.UI { public const float CATCHER_SIZE = 106.75f; - public Func> CreateDrawableRepresentation; - public readonly Catcher MovableCatcher; private readonly CatchComboDisplay comboDisplay; - public Container ExplodingFruitTarget - { - set => MovableCatcher.ExplodingFruitTarget = value; - } - - private DrawableCatchHitObject lastPlateableFruit; - - public CatcherArea(BeatmapDifficulty difficulty = null) + public CatcherArea(Container droppedObjectContainer, BeatmapDifficulty difficulty = null) { Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); Children = new Drawable[] @@ -47,70 +36,29 @@ namespace osu.Game.Rulesets.Catch.UI Margin = new MarginPadding { Bottom = 350f }, X = CatchPlayfield.CENTER_X }, - MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, + MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X }, }; } - public void OnNewResult(DrawableCatchHitObject fruit, JudgementResult result) + public void OnNewResult(DrawableCatchHitObject hitObject, JudgementResult result) { if (!result.Type.IsScorable()) return; - void runAfterLoaded(Action action) - { - if (lastPlateableFruit == null) - return; - - // this is required to make this run after the last caught fruit runs updateState() at least once. - // TODO: find a better alternative - if (lastPlateableFruit.IsLoaded) - action(); - else - lastPlateableFruit.OnLoadComplete += _ => action(); - } - - if (result.IsHit && fruit.HitObject.CanBePlated) - { - // create a new (cloned) fruit to stay on the plate. the original is faded out immediately. - var caughtFruit = (DrawableCatchHitObject)CreateDrawableRepresentation?.Invoke(fruit.HitObject); - - if (caughtFruit == null) return; - - caughtFruit.RelativePositionAxes = Axes.None; - caughtFruit.Position = new Vector2(MovableCatcher.ToLocalSpace(fruit.ScreenSpaceDrawQuad.Centre).X - MovableCatcher.DrawSize.X / 2, 0); - caughtFruit.IsOnPlate = true; - - caughtFruit.Anchor = Anchor.TopCentre; - caughtFruit.Origin = Anchor.Centre; - caughtFruit.Scale *= 0.5f; - caughtFruit.LifetimeStart = caughtFruit.HitObject.StartTime; - caughtFruit.LifetimeEnd = double.MaxValue; - - MovableCatcher.PlaceOnPlate(caughtFruit); - lastPlateableFruit = caughtFruit; - - if (!fruit.StaysOnPlate) - runAfterLoaded(() => MovableCatcher.Explode(caughtFruit)); - } - - if (fruit.HitObject.LastInCombo) + if (hitObject.HitObject.LastInCombo) { if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result)) - runAfterLoaded(() => MovableCatcher.Explode()); + MovableCatcher.Explode(); else MovableCatcher.Drop(); } - comboDisplay.OnNewResult(fruit, result); + comboDisplay.OnNewResult(hitObject, result); } public void OnRevertResult(DrawableCatchHitObject fruit, JudgementResult result) => comboDisplay.OnRevertResult(fruit, result); - public void OnReleased(CatchAction action) - { - } - public bool AttemptCatch(CatchHitObject obj) { return MovableCatcher.AttemptCatch(obj); diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index ebe45aa3ab..46733181e3 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -8,7 +8,6 @@ using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -40,30 +39,6 @@ namespace osu.Game.Rulesets.Catch.UI protected override PassThroughInputManager CreateInputManager() => new CatchInputManager(Ruleset.RulesetInfo); - public override DrawableHitObject CreateDrawableRepresentation(CatchHitObject h) - { - switch (h) - { - case Banana banana: - return new DrawableBanana(banana); - - case Fruit fruit: - return new DrawableFruit(fruit); - - case JuiceStream stream: - return new DrawableJuiceStream(stream, CreateDrawableRepresentation); - - case BananaShower shower: - return new DrawableBananaShower(shower, CreateDrawableRepresentation); - - case TinyDroplet tiny: - return new DrawableTinyDroplet(tiny); - - case Droplet droplet: - return new DrawableDroplet(droplet); - } - - return null; - } + public override DrawableHitObject CreateDrawableRepresentation(CatchHitObject h) => null; } } diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs index 04a86f83be..24ca778248 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs @@ -5,35 +5,43 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Pooling; using osu.Framework.Utils; -using osu.Game.Rulesets.Catch.Objects.Drawables; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { - public class HitExplosion : CompositeDrawable + public class HitExplosion : PoolableDrawable { - private readonly CircularContainer largeFaint; + private Color4 objectColour; - public HitExplosion(DrawableCatchHitObject fruit) + public Color4 ObjectColour + { + get => objectColour; + set + { + if (objectColour == value) return; + + objectColour = value; + onColourChanged(); + } + } + + private readonly CircularContainer largeFaint; + private readonly CircularContainer smallFaint; + private readonly CircularContainer directionalGlow1; + private readonly CircularContainer directionalGlow2; + + public HitExplosion() { Size = new Vector2(20); Anchor = Anchor.TopCentre; Origin = Anchor.BottomCentre; - Color4 objectColour = fruit.AccentColour.Value; - // scale roughly in-line with visual appearance of notes - - const float angle_variangle = 15; // should be less than 45 - - const float roundness = 100; - const float initial_height = 10; - var colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1); - InternalChildren = new Drawable[] { largeFaint = new CircularContainer @@ -42,33 +50,17 @@ namespace osu.Game.Rulesets.Catch.UI Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Masking = true, - // we want our size to be very small so the glow dominates it. - Size = new Vector2(0.8f), Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), - Roundness = 160, - Radius = 200, - }, }, - new CircularContainer + smallFaint = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Masking = true, Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), - Roundness = 20, - Radius = 50, - }, }, - new CircularContainer + directionalGlow1 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -76,16 +68,8 @@ namespace osu.Game.Rulesets.Catch.UI Masking = true, Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, - Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, }, - new CircularContainer + directionalGlow2 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -93,30 +77,57 @@ namespace osu.Game.Rulesets.Catch.UI Masking = true, Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, - Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, } }; } - protected override void LoadComplete() + protected override void PrepareForUse() { - base.LoadComplete(); + base.PrepareForUse(); const double duration = 400; + // we want our size to be very small so the glow dominates it. + largeFaint.Size = new Vector2(0.8f); largeFaint .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) .FadeOut(duration * 2); + const float angle_variangle = 15; // should be less than 45 + directionalGlow1.Rotation = RNG.NextSingle(-angle_variangle, angle_variangle); + directionalGlow2.Rotation = RNG.NextSingle(-angle_variangle, angle_variangle); + this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out); Expire(true); } + + private void onColourChanged() + { + const float roundness = 100; + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }; + + smallFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }; + + directionalGlow1.EdgeEffect = directionalGlow2.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs index 654b752001..538a51db5f 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -96,6 +96,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor throw new System.NotImplementedException(); } + public override SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) + { + throw new System.NotImplementedException(); + } + public override float GetBeatSnapDistanceAt(double referenceTime) { throw new System.NotImplementedException(); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs index a4d4ec50f8..dcb25f21ba 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs @@ -24,7 +24,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning if (hitWindows.IsHitResultAllowed(result)) { AddStep("Show " + result.GetDescription(), () => SetContents(() => - new DrawableManiaJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) + new DrawableManiaJudgement(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement()) + { + Type = result + }, null) { Anchor = Anchor.Centre, Origin = Anchor.Centre, 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 892f27d27f..fa7bfd7169 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 @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index b5ec1e1a2a..1f92929392 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -78,9 +78,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints private double originalStartTime; - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); if (PlacementActive) { diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index 27a279e044..5e09054667 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -48,9 +48,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints return true; } - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); if (!PlacementActive) Column = result.Playfield as Column; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index 684004b558..3db89c8ae6 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -22,9 +22,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre }; } - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); if (result.Playfield != null) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs index ff77df0ae0..4093aeb2a7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModEasy : ModEasy + public class ManiaModEasy : ModEasyWithExtraLives { public override string Description => @"More forgiving HP drain, less accuracy required, and three lives!"; } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index a4029e7893..3a00933e4d 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public void UpdateResult() => base.UpdateResult(true); + protected override double MaximumJudgementOffset => base.MaximumJudgementOffset * release_window_lenience; + protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs index f265419aa0..98931dceed 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs @@ -16,8 +16,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableHoldNoteTick : DrawableManiaHitObject { - public override bool DisplayResult => false; - /// /// References the time at which the user started holding the hold note. /// diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index c28a1c13d8..d2a9b69b60 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Mania.UI DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject; maniaObject.CheckHittable = hitPolicy.IsHittable; - HitObjectContainer.Add(hitObject); + base.Add(hitObject); } public override bool Remove(DrawableHitObject h) @@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Mania.UI if (result.IsHit) hitPolicy.HandleHit(judgedObject); - if (!result.IsHit || !DisplayJudgements.Value) + if (!result.IsHit || !judgedObject.DisplayResult || !DisplayJudgements.Value) return; HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index d99f6cb8d3..a3dcd0e57f 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Mania.UI { @@ -19,22 +20,52 @@ namespace osu.Game.Rulesets.Mania.UI { } - [BackgroundDependencyLoader] - private void load() + protected override void ApplyMissAnimations() { - if (JudgementText != null) - JudgementText.Font = JudgementText.Font.With(size: 25); - } + if (!(JudgementBody.Drawable is DefaultManiaJudgementPiece)) + { + // this is temporary logic until mania's skin transformer returns IAnimatableJudgements + JudgementBody.ScaleTo(1.6f); + JudgementBody.ScaleTo(1, 100, Easing.In); - protected override double FadeInDuration => 50; + JudgementBody.MoveTo(Vector2.Zero); + JudgementBody.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + JudgementBody.RotateTo(0); + JudgementBody.RotateTo(40, 800, Easing.InQuint); + JudgementBody.FadeOutFromOne(800); + + LifetimeEnd = JudgementBody.LatestTransformEndTime; + } + + base.ApplyMissAnimations(); + } protected override void ApplyHitAnimations() { JudgementBody.ScaleTo(0.8f); JudgementBody.ScaleTo(1, 250, Easing.OutElastic); - JudgementBody.Delay(FadeInDuration).ScaleTo(0.75f, 250); - this.Delay(FadeInDuration).FadeOut(200); + JudgementBody.Delay(50) + .ScaleTo(0.75f, 250) + .FadeOut(200); + } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); + + private class DefaultManiaJudgementPiece : DefaultJudgementPiece + { + public DefaultManiaJudgementPiece(HitResult result) + : base(result) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + JudgementText.Font = JudgementText.Font.With(size: 25); + } } } } diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index e7a2de266d..3d7960ffe3 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -167,6 +167,10 @@ namespace osu.Game.Rulesets.Mania.UI if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; + // Tick judgements should not display text. + if (judgedObject is DrawableHoldNoteTick) + return; + judgements.Clear(false); judgements.Add(judgementPool.Get(j => { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs new file mode 100644 index 0000000000..a652fb32f4 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs @@ -0,0 +1,41 @@ +// 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.Beatmaps; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Tests.Beatmaps; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public class TestSceneObjectBeatSnap : TestSceneOsuEditor + { + private OsuPlayfield playfield; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false); + + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("get playfield", () => playfield = Editor.ChildrenOfType().First()); + } + + [Test] + public void TestBeatSnapHitCircle() + { + double firstTimingPointTime() => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.First().Time; + + AddStep("seek some milliseconds forward", () => EditorClock.Seek(firstTimingPointTime() + 10)); + + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre)); + AddStep("enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("place first object", () => InputManager.Click(MouseButton.Left)); + + AddAssert("ensure object snapped back to correct time", () => EditorBeatmap.HitObjects.First().StartTime == firstTimingPointTime()); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs index 1ca94df26b..7bdf131e0d 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs @@ -25,6 +25,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { base.SetUpSteps(); AddStep("get playfield", () => playfield = Editor.ChildrenOfType().First()); + AddStep("seek to first control point", () => EditorClock.Seek(Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.First().Time)); } [TestCase(true)] @@ -51,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor var first = (OsuHitObject)objects.First(); var second = (OsuHitObject)objects.Last(); - return first.Position == second.Position; + return Precision.AlmostEquals(first.EndPosition, second.Position); }); } @@ -66,13 +67,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("start slider placement", () => InputManager.Click(MouseButton.Left)); - AddStep("move to place end", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.185f, 0))); + AddStep("move to place end", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.225f, 0))); AddStep("end slider placement", () => InputManager.Click(MouseButton.Right)); AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2)); - AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.20f, 0))); + AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.235f, 0))); AddStep("place second object", () => InputManager.Click(MouseButton.Left)); @@ -86,5 +87,64 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor return Precision.AlmostEquals(first.EndPosition, second.Position); }); } + + [Test] + public void TestSecondCircleInSelectionAlsoSnaps() + { + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre)); + + AddStep("disable distance snap", () => InputManager.Key(Key.Q)); + + AddStep("enter placement mode", () => InputManager.Key(Key.Number2)); + + AddStep("place first object", () => InputManager.Click(MouseButton.Left)); + + AddStep("increment time", () => EditorClock.SeekForward(true)); + + AddStep("move mouse right", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.2f, 0))); + AddStep("place second object", () => InputManager.Click(MouseButton.Left)); + + AddStep("increment time", () => EditorClock.SeekForward(true)); + + AddStep("move mouse down", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(0, playfield.ScreenSpaceDrawQuad.Width * 0.2f))); + AddStep("place third object", () => InputManager.Click(MouseButton.Left)); + + AddStep("enter selection mode", () => InputManager.Key(Key.Number1)); + + AddStep("select objects 2 and 3", () => + { + // add selection backwards to test non-sequential time ordering + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[2]); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[1]); + }); + + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("move mouse slightly off centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.02f, 0))); + + AddAssert("object 3 snapped to 1", () => + { + var objects = EditorBeatmap.HitObjects; + + var first = (OsuHitObject)objects.First(); + var third = (OsuHitObject)objects.Last(); + + return Precision.AlmostEquals(first.EndPosition, third.Position); + }); + + AddStep("move mouse slightly off centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * -0.22f, playfield.ScreenSpaceDrawQuad.Width * 0.21f))); + + AddAssert("object 2 snapped to 1", () => + { + var objects = EditorBeatmap.HitObjects; + + var first = (OsuHitObject)objects.First(); + var second = (OsuHitObject)objects.ElementAt(1); + + return Precision.AlmostEquals(first.EndPosition, second.Position); + }); + + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index 1232369a0b..9af2a99470 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -174,6 +174,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private class SnapProvider : IPositionSnapProvider { + public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => + new SnapResult(screenSpacePosition, null); + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length; diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs index 7697f46160..d3cb3bcf59 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs @@ -5,7 +5,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class OsuModTestScene : ModTestScene + public abstract class OsuModTestScene : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs index 40f1c4a52f..1ac3ad9194 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -2,12 +2,15 @@ // 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.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Play; using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods @@ -17,15 +20,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData { - Mod = new OsuModHidden(), + Mod = new TestOsuModHidden(), Autoplay = true, - PassCondition = checkSomeHit + PassCondition = () => checkSomeHit() && objectWithIncreasedVisibilityHasIndex(0) }); [Test] public void FirstCircleAfterTwoSpinners() => CreateModTest(new ModTestData { - Mod = new OsuModHidden(), + Mod = new TestOsuModHidden(), Autoplay = true, Beatmap = new Beatmap { @@ -54,13 +57,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods } } }, - PassCondition = checkSomeHit + PassCondition = () => checkSomeHit() && objectWithIncreasedVisibilityHasIndex(2) }); [Test] public void FirstSliderAfterTwoSpinners() => CreateModTest(new ModTestData { - Mod = new OsuModHidden(), + Mod = new TestOsuModHidden(), Autoplay = true, Beatmap = new Beatmap { @@ -89,12 +92,41 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods } } }, + PassCondition = () => checkSomeHit() && objectWithIncreasedVisibilityHasIndex(2) + }); + + [Test] + public void TestWithSliderReuse() => CreateModTest(new ModTestData + { + Mod = new TestOsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }, + new Slider + { + StartTime = 4000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }, + } + }, PassCondition = checkSomeHit }); - private bool checkSomeHit() + private bool checkSomeHit() => Player.ScoreProcessor.JudgedHits >= 4; + + private bool objectWithIncreasedVisibilityHasIndex(int index) + => Player.Mods.Value.OfType().Single().FirstObject == Player.ChildrenOfType().Single().HitObjects[index]; + + private class TestOsuModHidden : OsuModHidden { - return Player.ScoreProcessor.JudgedHits >= 4; + public new HitObject FirstObject => base.FirstObject; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index 646f12f710..e4158d8f07 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -43,10 +43,8 @@ namespace osu.Game.Rulesets.Osu.Tests showResult(HitResult.Great); AddUntilStep("judgements shown", () => this.ChildrenOfType().Any()); - AddAssert("judgement body immediately visible", - () => this.ChildrenOfType().All(judgement => judgement.JudgementBody.Alpha == 1)); - AddAssert("hit lighting hidden", - () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha == 0)); + AddAssert("hit lighting has no transforms", () => this.ChildrenOfType().All(judgement => !judgement.Lighting.Transforms.Any())); + AddAssert("hit lighting hidden", () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha == 0)); } [Test] @@ -57,10 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests showResult(HitResult.Great); AddUntilStep("judgements shown", () => this.ChildrenOfType().Any()); - AddAssert("judgement body not immediately visible", - () => this.ChildrenOfType().All(judgement => judgement.JudgementBody.Alpha > 0 && judgement.JudgementBody.Alpha < 1)); - AddAssert("hit lighting shown", - () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha > 0)); + AddUntilStep("hit lighting shown", () => this.ChildrenOfType().Any(judgement => judgement.Lighting.Alpha > 0)); } private void showResult(HitResult result) @@ -89,7 +84,13 @@ namespace osu.Game.Rulesets.Osu.Tests Children = new Drawable[] { pool, - pool.Get(j => j.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null)).With(j => + pool.Get(j => j.Apply(new JudgementResult(new HitObject + { + StartTime = Time.Current + }, new Judgement()) + { + Type = result, + }, null)).With(j => { j.Anchor = Anchor.Centre; j.Origin = Anchor.Centre; @@ -106,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Tests private class TestDrawableOsuJudgement : DrawableOsuJudgement { public new SkinnableSprite Lighting => base.Lighting; - public new Container JudgementBody => base.JudgementBody; + public new SkinnableDrawable JudgementBody => base.JudgementBody; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs index 6c077eb214..fe67b63252 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -94,9 +95,19 @@ namespace osu.Game.Rulesets.Osu.Tests { addMultipleObjectsStep(); - AddStep("move hitobject", () => getObject(2).HitObject.Position = new Vector2(300, 100)); + AddStep("move hitobject", () => + { + var manualClock = new ManualClock(); + followPointRenderer.Clock = new FramedClock(manualClock); + + manualClock.CurrentTime = getObject(1).HitObject.StartTime; + followPointRenderer.UpdateSubTree(); + + getObject(2).HitObject.Position = new Vector2(300, 100); + }); assertGroups(); + assertDirections(); } [TestCase(0, 0)] // Start -> Start @@ -207,7 +218,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void assertGroups() { - AddAssert("has correct group count", () => followPointRenderer.Connections.Count == hitObjectContainer.Count); + AddAssert("has correct group count", () => followPointRenderer.Entries.Count == hitObjectContainer.Count); AddAssert("group endpoints are correct", () => { for (int i = 0; i < hitObjectContainer.Count; i++) @@ -215,10 +226,10 @@ namespace osu.Game.Rulesets.Osu.Tests DrawableOsuHitObject expectedStart = getObject(i); DrawableOsuHitObject expectedEnd = i < hitObjectContainer.Count - 1 ? getObject(i + 1) : null; - if (getGroup(i).Start != expectedStart.HitObject) + if (getEntry(i).Start != expectedStart.HitObject) throw new AssertionException($"Object {i} expected to be the start of group {i}."); - if (getGroup(i).End != expectedEnd?.HitObject) + if (getEntry(i).End != expectedEnd?.HitObject) throw new AssertionException($"Object {(expectedEnd == null ? "null" : i.ToString())} expected to be the end of group {i}."); } @@ -238,6 +249,12 @@ namespace osu.Game.Rulesets.Osu.Tests if (expectedEnd == null) continue; + var manualClock = new ManualClock(); + followPointRenderer.Clock = new FramedClock(manualClock); + + manualClock.CurrentTime = expectedStart.HitObject.StartTime; + followPointRenderer.UpdateSubTree(); + var points = getGroup(i).ChildrenOfType().ToArray(); if (points.Length == 0) continue; @@ -255,7 +272,9 @@ namespace osu.Game.Rulesets.Osu.Tests private DrawableOsuHitObject getObject(int index) => hitObjectContainer[index]; - private FollowPointConnection getGroup(int index) => followPointRenderer.Connections[index]; + private FollowPointLifetimeEntry getEntry(int index) => followPointRenderer.Entries[index]; + + private FollowPointConnection getGroup(int index) => followPointRenderer.ChildrenOfType().Single(c => c.Entry == getEntry(index)); private class TestHitObjectContainer : Container { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index 596bc06c68..1278a0ff2d 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -1,17 +1,17 @@ // 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; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osuTK; -using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Tests { @@ -38,13 +38,37 @@ namespace osu.Game.Rulesets.Osu.Tests } private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) + { + var drawable = createSingle(circleSize, auto, timeOffset, positionOffset); + + var playfield = new TestOsuPlayfield(); + playfield.Add(drawable); + return playfield; + } + + private Drawable testStream(float circleSize, bool auto = false) + { + var playfield = new TestOsuPlayfield(); + + Vector2 pos = new Vector2(-250, 0); + + for (int i = 0; i <= 1000; i += 100) + { + playfield.Add(createSingle(circleSize, auto, i, pos)); + pos.X += 50; + } + + return playfield; + } + + private TestDrawableHitCircle createSingle(float circleSize, bool auto, double timeOffset, Vector2? positionOffset) { positionOffset ??= Vector2.Zero; var circle = new HitCircle { StartTime = Time.Current + 1000 + timeOffset, - Position = positionOffset.Value, + Position = OsuPlayfield.BASE_SIZE / 4 + positionOffset.Value, }; circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); @@ -53,31 +77,14 @@ namespace osu.Game.Rulesets.Osu.Tests foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToDrawableHitObjects(new[] { drawable }); - return drawable; } protected virtual TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) => new TestDrawableHitCircle(circle, auto) { - Anchor = Anchor.Centre, Depth = depthIndex++ }; - private Drawable testStream(float circleSize, bool auto = false) - { - var container = new Container { RelativeSizeAxes = Axes.Both }; - - Vector2 pos = new Vector2(-250, 0); - - for (int i = 0; i <= 1000; i += 100) - { - container.Add(testSingle(circleSize, auto, i, pos)); - pos.X += 50; - } - - return container; - } - protected class TestDrawableHitCircle : DrawableHitCircle { private readonly bool auto; @@ -101,5 +108,13 @@ namespace osu.Game.Rulesets.Osu.Tests base.CheckForResult(userTriggered, timeOffset); } } + + protected class TestOsuPlayfield : OsuPlayfield + { + public TestOsuPlayfield() + { + RelativeSizeAxes = Axes.Both; + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index d692be89b2..7e973d0971 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Diagnostics; +using osu.Framework.Threading; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; @@ -10,6 +13,19 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneShaking : TestSceneHitCircle { + private readonly List scheduledTasks = new List(); + + protected override IBeatmap CreateBeatmapForSkinProvider() + { + // best way to run cleanup before a new step is run + foreach (var task in scheduledTasks) + task.Cancel(); + + scheduledTasks.Clear(); + + return base.CreateBeatmapForSkinProvider(); + } + protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) { var drawableHitObject = base.CreateDrawableHitCircle(circle, auto); @@ -17,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Tests Debug.Assert(drawableHitObject.HitObject.HitWindows != null); double delay = drawableHitObject.HitObject.StartTime - (drawableHitObject.HitObject.HitWindows.WindowFor(HitResult.Miss) + RNG.Next(0, 300)) - Time.Current; - Scheduler.AddDelayed(() => drawableHitObject.TriggerJudgement(), delay); + scheduledTasks.Add(Scheduler.AddDelayed(() => drawableHitObject.TriggerJudgement(), delay)); return drawableHitObject; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index c400e2f2ea..d40484f5ed 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -108,8 +108,8 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("change samples", () => slider.HitObject.Samples = new[] { - new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP }, - new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE }, + new HitSampleInfo(HitSampleInfo.HIT_CLAP), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE), }); AddAssert("head samples updated", () => assertSamples(slider.HitObject.HeadCircle)); @@ -136,15 +136,15 @@ namespace osu.Game.Rulesets.Osu.Tests slider = (DrawableSlider)createSlider(repeats: 1); for (int i = 0; i < 2; i++) - slider.HitObject.NodeSamples.Add(new List { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } }); + slider.HitObject.NodeSamples.Add(new List { new HitSampleInfo(HitSampleInfo.HIT_FINISH) }); Add(slider); }); AddStep("change samples", () => slider.HitObject.Samples = new[] { - new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP }, - new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE }, + new HitSampleInfo(HitSampleInfo.HIT_CLAP), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE), }); AddAssert("head samples not updated", () => assertSamples(slider.HitObject.HeadCircle)); 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 3639c3616f..d6a03da807 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 @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index e14d6647d2..c45a04053f 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -45,9 +45,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles return base.OnMouseDown(e); } - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs index ba1d35c35c..eb7011e8b0 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private readonly Path path; private readonly Slider slider; - private readonly int controlPointIndex; + public int ControlPointIndex { get; set; } private IBindable sliderPosition; private IBindable pathVersion; @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public PathControlPointConnectionPiece(Slider slider, int controlPointIndex) { this.slider = slider; - this.controlPointIndex = controlPointIndex; + ControlPointIndex = controlPointIndex; Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components path.ClearVertices(); - int nextIndex = controlPointIndex + 1; + int nextIndex = ControlPointIndex + 1; if (nextIndex == 0 || nextIndex >= slider.Path.ControlPoints.Count) return; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index c06904c0c2..e9838de63d 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -44,6 +44,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private OsuColour colours { get; set; } private IBindable sliderPosition; + private IBindable sliderScale; private IBindable controlPointPosition; public PathControlPointPiece(Slider slider, PathControlPoint controlPoint) @@ -69,13 +70,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(10), + Size = new Vector2(20), }, markerRing = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(14), + Size = new Vector2(28), Masking = true, BorderThickness = 2, BorderColour = Color4.White, @@ -102,6 +103,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components controlPointPosition = ControlPoint.Position.GetBoundCopy(); controlPointPosition.BindValueChanged(_ => updateMarkerDisplay()); + sliderScale = slider.ScaleBindable.GetBoundCopy(); + sliderScale.BindValueChanged(_ => updateMarkerDisplay()); + IsSelected.BindValueChanged(_ => updateMarkerDisplay()); updateMarkerDisplay(); @@ -143,6 +147,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components protected override bool OnClick(ClickEvent e) => RequestSelection != null; + private Vector2 dragStartPosition; + protected override bool OnDragStart(DragStartEvent e) { if (RequestSelection == null) @@ -150,6 +156,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (e.Button == MouseButton.Left) { + dragStartPosition = ControlPoint.Position.Value; changeHandler?.BeginChange(); return true; } @@ -174,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components slider.Path.ControlPoints[i].Position.Value -= movementDelta; } else - ControlPoint.Position.Value += e.Delta; + ControlPoint.Position.Value = dragStartPosition + (e.MousePosition - e.MouseDownPosition); } protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange(); @@ -194,6 +201,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components colour = colour.Lighten(1); marker.Colour = colour; + marker.Scale = new Vector2(slider.Scale); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 17541866ec..ce5dc4855e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -20,12 +20,15 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; +using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { public class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside of the playfield. + internal readonly Container Pieces; internal readonly Container Connections; @@ -66,6 +69,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components switch (e.Action) { case NotifyCollectionChangedAction.Add: + // If inserting in the path (not appending), + // update indices of existing connections after insert location + if (e.NewStartingIndex < Pieces.Count) + { + foreach (var connection in Connections) + { + if (connection.ControlPointIndex >= e.NewStartingIndex) + connection.ControlPointIndex += e.NewItems.Count; + } + } + for (int i = 0; i < e.NewItems.Count; i++) { var point = (PathControlPoint)e.NewItems[i]; @@ -88,6 +102,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components Connections.RemoveAll(c => c.ControlPoint == point); } + // If removing before the end of the path, + // update indices of connections after remove location + if (e.OldStartingIndex < Pieces.Count) + { + foreach (var connection in Connections) + { + if (connection.ControlPointIndex >= e.OldStartingIndex) + connection.ControlPointIndex -= e.OldItems.Count; + } + } + break; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 4b99cc23ed..b71e1914f7 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -67,9 +67,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders inputManager = GetContainingInputManager(); } - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); switch (state) { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 7ae4f387ca..d592e129d9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -44,6 +44,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } + private readonly BindableList controlPoints = new BindableList(); + private readonly IBindable pathVersion = new Bindable(); + public SliderSelectionBlueprint(DrawableSlider slider) : base(slider) { @@ -61,13 +64,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders }; } - private IBindable pathVersion; - protected override void LoadComplete() { base.LoadComplete(); - pathVersion = HitObject.Path.Version.GetBoundCopy(); + controlPoints.BindTo(HitObject.Path.ControlPoints); + + pathVersion.BindTo(HitObject.Path.Version); pathVersion.BindValueChanged(_ => updatePath()); BodyPiece.UpdateFrom(HitObject); @@ -164,8 +167,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } - private BindableList controlPoints => HitObject.Path.ControlPoints; - private int addControlPoint(Vector2 position) { position -= HitObject.Position; diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditPool.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditPool.cs deleted file mode 100644 index 776aacd143..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditPool.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables; - -namespace osu.Game.Rulesets.Osu.Edit -{ - public class DrawableOsuEditPool : DrawableOsuPool - where T : DrawableHitObject, new() - { - /// - /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. - /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. - /// - private const double editor_hit_object_fade_out_extension = 700; - - public DrawableOsuEditPool(Func checkHittable, Action onLoaded, int initialSize, int? maximumSize = null) - : base(checkHittable, onLoaded, initialSize, maximumSize) - { - } - - protected override T CreateNewDrawable() => base.CreateNewDrawable().With(d => d.ApplyCustomUpdateState += updateState); - - private void updateState(DrawableHitObject hitObject, ArmedState state) - { - if (state == ArmedState.Idle) - return; - - // adjust the visuals of certain object types to make them stay on screen for longer than usual. - switch (hitObject) - { - default: - // there are quite a few drawable hit types we don't want to extend (spinners, ticks etc.) - return; - - case DrawableSlider _: - // no specifics to sliders but let them fade slower below. - break; - - case DrawableHitCircle circle: // also handles slider heads - circle.ApproachCircle - .FadeOutFromOne(editor_hit_object_fade_out_extension) - .Expire(); - break; - } - - // Get the existing fade out transform - var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha)); - - if (existing == null) - return; - - hitObject.RemoveTransform(existing); - - using (hitObject.BeginAbsoluteSequence(existing.StartTime)) - hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs index 547dff88b5..5fdb79cbbd 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using osu.Framework.Graphics.Pooling; +using System.Linq; +using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osuTK; @@ -26,8 +29,51 @@ namespace osu.Game.Rulesets.Osu.Edit { protected override GameplayCursorContainer CreateCursor() => null; - protected override DrawablePool CreatePool(int initialSize, int? maximumSize = null) - => new DrawableOsuEditPool(CheckHittable, OnHitObjectLoaded, initialSize, maximumSize); + protected override void OnNewDrawableHitObject(DrawableHitObject d) + { + d.ApplyCustomUpdateState += updateState; + } + + /// + /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. + /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. + /// + private const double editor_hit_object_fade_out_extension = 700; + + private void updateState(DrawableHitObject hitObject, ArmedState state) + { + if (state == ArmedState.Idle) + return; + + // adjust the visuals of certain object types to make them stay on screen for longer than usual. + switch (hitObject) + { + default: + // there are quite a few drawable hit types we don't want to extend (spinners, ticks etc.) + return; + + case DrawableSlider _: + // no specifics to sliders but let them fade slower below. + break; + + case DrawableHitCircle circle: // also handles slider heads + circle.ApproachCircle + .FadeOutFromOne(editor_hit_object_fade_out_extension) + .Expire(); + break; + } + + // Get the existing fade out transform + var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha)); + + if (existing == null) + return; + + hitObject.RemoveTransform(existing); + + using (hitObject.BeginAbsoluteSequence(existing.StartTime)) + hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); + } } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index bfa8ab4431..0490e8b8ce 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -105,11 +105,20 @@ namespace osu.Game.Rulesets.Osu.Edit } } - public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + public override SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) { if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) return snapResult; + return new SnapResult(screenSpacePosition, null); + } + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + var positionSnap = SnapScreenSpacePositionToValidPosition(screenSpacePosition); + if (positionSnap.ScreenSpacePosition != screenSpacePosition) + return positionSnap; + // will be null if distance snap is disabled or not feasible for the current time value. if (distanceSnapGrid == null) return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index f13c7d2ff6..06b5b6cfb8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModEasy : ModEasy + public class OsuModEasy : ModEasyWithExtraLives { public override string Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 7c1dd46c02..45f314af7b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -2,9 +2,10 @@ // 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.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -23,25 +24,21 @@ namespace osu.Game.Rulesets.Osu.Mods private const double fade_in_duration_multiplier = 0.4; private const double fade_out_duration_multiplier = 0.3; - protected override bool IsFirstAdjustableObject(HitObject hitObject) => !(hitObject is Spinner); + protected override bool IsFirstAdjustableObject(HitObject hitObject) => !(hitObject is Spinner || hitObject is SpinnerTick); - public override void ApplyToDrawableHitObjects(IEnumerable drawables) + public override void ApplyToBeatmap(IBeatmap beatmap) { - foreach (var d in drawables) - d.HitObjectApplied += applyFadeInAdjustment; + base.ApplyToBeatmap(beatmap); - base.ApplyToDrawableHitObjects(drawables); - } + foreach (var obj in beatmap.HitObjects.OfType()) + applyFadeInAdjustment(obj); - private void applyFadeInAdjustment(DrawableHitObject hitObject) - { - if (!(hitObject is DrawableOsuHitObject d)) - return; - - d.HitObject.TimeFadeIn = d.HitObject.TimePreempt * fade_in_duration_multiplier; - - foreach (var nested in d.NestedHitObjects) - applyFadeInAdjustment(nested); + static void applyFadeInAdjustment(OsuHitObject osuObject) + { + osuObject.TimeFadeIn = osuObject.TimePreempt * fade_in_duration_multiplier; + foreach (var nested in osuObject.NestedHitObjects.OfType()) + applyFadeInAdjustment(nested); + } } protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) @@ -56,37 +53,27 @@ namespace osu.Game.Rulesets.Osu.Mods applyState(hitObject, false); } - private void applyState(DrawableHitObject drawable, bool increaseVisibility) + private void applyState(DrawableHitObject drawableObject, bool increaseVisibility) { - if (!(drawable is DrawableOsuHitObject d)) + if (!(drawableObject is DrawableOsuHitObject drawableOsuObject)) return; - var h = d.HitObject; + OsuHitObject hitObject = drawableOsuObject.HitObject; - var fadeOutStartTime = h.StartTime - h.TimePreempt + h.TimeFadeIn; - var fadeOutDuration = h.TimePreempt * fade_out_duration_multiplier; + (double startTime, double duration) fadeOut = getFadeOutParameters(drawableOsuObject); - // new duration from completed fade in to end (before fading out) - var longFadeDuration = h.GetEndTime() - fadeOutStartTime; - - switch (drawable) + switch (drawableObject) { - case DrawableSliderTail sliderTail: - // use stored values from head circle to achieve same fade sequence. - var tailFadeOutParameters = getFadeOutParametersFromSliderHead(h); - - using (drawable.BeginAbsoluteSequence(tailFadeOutParameters.startTime, true)) - sliderTail.FadeOut(tailFadeOutParameters.duration); + case DrawableSliderTail _: + using (drawableObject.BeginAbsoluteSequence(fadeOut.startTime, true)) + drawableObject.FadeOut(fadeOut.duration); break; case DrawableSliderRepeat sliderRepeat: - // use stored values from head circle to achieve same fade sequence. - var repeatFadeOutParameters = getFadeOutParametersFromSliderHead(h); - - using (drawable.BeginAbsoluteSequence(repeatFadeOutParameters.startTime, true)) + using (drawableObject.BeginAbsoluteSequence(fadeOut.startTime, true)) // only apply to circle piece – reverse arrow is not affected by hidden. - sliderRepeat.CirclePiece.FadeOut(repeatFadeOutParameters.duration); + sliderRepeat.CirclePiece.FadeOut(fadeOut.duration); break; @@ -101,29 +88,23 @@ namespace osu.Game.Rulesets.Osu.Mods else { // we don't want to see the approach circle - using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + using (circle.BeginAbsoluteSequence(hitObject.StartTime - hitObject.TimePreempt, true)) circle.ApproachCircle.Hide(); } - // fade out immediately after fade in. - using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true)) - fadeTarget.FadeOut(fadeOutDuration); + using (drawableObject.BeginAbsoluteSequence(fadeOut.startTime, true)) + fadeTarget.FadeOut(fadeOut.duration); break; case DrawableSlider slider: - associateNestedSliderCirclesWithHead(slider.HitObject); - - using (slider.BeginAbsoluteSequence(fadeOutStartTime, true)) - slider.Body.FadeOut(longFadeDuration, Easing.Out); + using (slider.BeginAbsoluteSequence(fadeOut.startTime, true)) + slider.Body.FadeOut(fadeOut.duration, Easing.Out); break; case DrawableSliderTick sliderTick: - // slider ticks fade out over up to one second - var tickFadeOutDuration = Math.Min(sliderTick.HitObject.TimePreempt - DrawableSliderTick.ANIM_DURATION, 1000); - - using (sliderTick.BeginAbsoluteSequence(sliderTick.HitObject.StartTime - tickFadeOutDuration, true)) - sliderTick.FadeOut(tickFadeOutDuration); + using (sliderTick.BeginAbsoluteSequence(fadeOut.startTime, true)) + sliderTick.FadeOut(fadeOut.duration); break; @@ -131,30 +112,55 @@ namespace osu.Game.Rulesets.Osu.Mods // hide elements we don't care about. // todo: hide background - using (spinner.BeginAbsoluteSequence(fadeOutStartTime + longFadeDuration, true)) - spinner.FadeOut(fadeOutDuration); + using (spinner.BeginAbsoluteSequence(fadeOut.startTime, true)) + spinner.FadeOut(fadeOut.duration); break; } } - private readonly Dictionary correspondingSliderHeadForObject = new Dictionary(); - - private void associateNestedSliderCirclesWithHead(Slider slider) + private (double startTime, double duration) getFadeOutParameters(DrawableOsuHitObject drawableObject) { - var sliderHead = slider.NestedHitObjects.Single(obj => obj is SliderHeadCircle); - - foreach (var nested in slider.NestedHitObjects) + switch (drawableObject) { - if ((nested is SliderRepeat || nested is SliderEndCircle) && !correspondingSliderHeadForObject.ContainsKey(nested)) - correspondingSliderHeadForObject[nested] = (SliderHeadCircle)sliderHead; - } - } + case DrawableSliderTail tail: + // Use the same fade sequence as the slider head. + Debug.Assert(tail.Slider != null); + return getParameters(tail.Slider.HeadCircle); - private (double startTime, double duration) getFadeOutParametersFromSliderHead(OsuHitObject h) - { - var sliderHead = correspondingSliderHeadForObject[h]; - return (sliderHead.StartTime - sliderHead.TimePreempt + sliderHead.TimeFadeIn, sliderHead.TimePreempt * fade_out_duration_multiplier); + case DrawableSliderRepeat repeat: + // Use the same fade sequence as the slider head. + Debug.Assert(repeat.Slider != null); + return getParameters(repeat.Slider.HeadCircle); + + default: + return getParameters(drawableObject.HitObject); + } + + static (double startTime, double duration) getParameters(OsuHitObject hitObject) + { + var fadeOutStartTime = hitObject.StartTime - hitObject.TimePreempt + hitObject.TimeFadeIn; + var fadeOutDuration = hitObject.TimePreempt * fade_out_duration_multiplier; + + // new duration from completed fade in to end (before fading out) + var longFadeDuration = hitObject.GetEndTime() - fadeOutStartTime; + + switch (hitObject) + { + case Slider _: + return (fadeOutStartTime, longFadeDuration); + + case SliderTick _: + var tickFadeOutDuration = Math.Min(hitObject.TimePreempt - DrawableSliderTick.ANIM_DURATION, 1000); + return (hitObject.StartTime - tickFadeOutDuration, tickFadeOutDuration); + + case Spinner _: + return (fadeOutStartTime + longFadeDuration, fadeOutDuration); + + default: + return (fadeOutStartTime, fadeOutDuration); + } + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs index a981648444..b989500066 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; using osuTK; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; using osu.Game.Skinning; @@ -15,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// A single follow point positioned between two adjacent s. /// - public class FollowPoint : Container, IAnimationTimeReference + public class FollowPoint : PoolableDrawable, IAnimationTimeReference { private const float width = 8; @@ -25,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { Origin = Anchor.Centre; - Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.FollowPoint), _ => new CircularContainer + InternalChild = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.FollowPoint), _ => new CircularContainer { Masking = true, AutoSizeAxes = Axes.Both, @@ -46,6 +48,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections }); } - public double AnimationStartTime { get; set; } + public Bindable AnimationStartTime { get; } = new BindableDouble(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 3a9e19b361..6e7b1050cb 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -2,11 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; -using JetBrains.Annotations; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects; using osuTK; @@ -15,150 +12,106 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// Visualises the s between two s. /// - public class FollowPointConnection : CompositeDrawable + public class FollowPointConnection : PoolableDrawable { // Todo: These shouldn't be constants - private const int spacing = 32; - private const double preempt = 800; + public const int SPACING = 32; + public const double PREEMPT = 800; - public override bool RemoveWhenNotAlive => false; + public FollowPointLifetimeEntry Entry; + public DrawablePool Pool; - /// - /// The start time of . - /// - public readonly Bindable StartTime = new BindableDouble(); - - /// - /// The which s will exit from. - /// - [NotNull] - public readonly OsuHitObject Start; - - /// - /// Creates a new . - /// - /// The which s will exit from. - public FollowPointConnection([NotNull] OsuHitObject start) + protected override void PrepareForUse() { - Start = start; + base.PrepareForUse(); - RelativeSizeAxes = Axes.Both; + Entry.Invalidated += onEntryInvalidated; - StartTime.BindTo(start.StartTimeBindable); + refreshPoints(); } - protected override void LoadComplete() + protected override void FreeAfterUse() { - base.LoadComplete(); - bindEvents(Start); + base.FreeAfterUse(); + + Entry.Invalidated -= onEntryInvalidated; + + // Return points to the pool. + ClearInternal(false); + + Entry = null; } - private OsuHitObject end; + private void onEntryInvalidated() => refreshPoints(); - /// - /// The which s will enter. - /// - [CanBeNull] - public OsuHitObject End + private void refreshPoints() { - get => end; - set - { - end = value; + ClearInternal(false); - if (end != null) - bindEvents(end); + OsuHitObject start = Entry.Start; + OsuHitObject end = Entry.End; - if (IsLoaded) - scheduleRefresh(); - else - refresh(); - } - } + double startTime = start.GetEndTime(); - private void bindEvents(OsuHitObject obj) - { - obj.PositionBindable.BindValueChanged(_ => scheduleRefresh()); - obj.DefaultsApplied += _ => scheduleRefresh(); - } - - private void scheduleRefresh() - { - Scheduler.AddOnce(refresh); - } - - private void refresh() - { - double startTime = Start.GetEndTime(); - - LifetimeStart = startTime; - - if (End == null || End.NewCombo || Start is Spinner || End is Spinner) - { - // ensure we always set a lifetime for full LifetimeManagementContainer benefits - LifetimeEnd = LifetimeStart; - return; - } - - Vector2 startPosition = Start.StackedEndPosition; - Vector2 endPosition = End.StackedPosition; - double endTime = End.StartTime; + Vector2 startPosition = start.StackedEndPosition; + Vector2 endPosition = end.StackedPosition; Vector2 distanceVector = endPosition - startPosition; int distance = (int)distanceVector.Length; float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI)); - double duration = endTime - startTime; - double? firstTransformStartTime = null; double finalTransformEndTime = startTime; - int point = 0; - - ClearInternal(); - - for (int d = (int)(spacing * 1.5); d < distance - spacing; d += spacing) + for (int d = (int)(SPACING * 1.5); d < distance - SPACING; d += SPACING) { float fraction = (float)d / distance; Vector2 pointStartPosition = startPosition + (fraction - 0.1f) * distanceVector; Vector2 pointEndPosition = startPosition + fraction * distanceVector; - double fadeOutTime = startTime + fraction * duration; - double fadeInTime = fadeOutTime - preempt; + + GetFadeTimes(start, end, (float)d / distance, out var fadeInTime, out var fadeOutTime); FollowPoint fp; - AddInternal(fp = new FollowPoint()); - - Debug.Assert(End != null); + AddInternal(fp = Pool.Get()); + fp.ClearTransforms(); fp.Position = pointStartPosition; fp.Rotation = rotation; fp.Alpha = 0; - fp.Scale = new Vector2(1.5f * End.Scale); + fp.Scale = new Vector2(1.5f * end.Scale); - firstTransformStartTime ??= fadeInTime; - - fp.AnimationStartTime = fadeInTime; + fp.AnimationStartTime.Value = fadeInTime; using (fp.BeginAbsoluteSequence(fadeInTime)) { - fp.FadeIn(End.TimeFadeIn); - fp.ScaleTo(End.Scale, End.TimeFadeIn, Easing.Out); - fp.MoveTo(pointEndPosition, End.TimeFadeIn, Easing.Out); - fp.Delay(fadeOutTime - fadeInTime).FadeOut(End.TimeFadeIn); + fp.FadeIn(end.TimeFadeIn); + fp.ScaleTo(end.Scale, end.TimeFadeIn, Easing.Out); + fp.MoveTo(pointEndPosition, end.TimeFadeIn, Easing.Out); + fp.Delay(fadeOutTime - fadeInTime).FadeOut(end.TimeFadeIn); - finalTransformEndTime = fadeOutTime + End.TimeFadeIn; + finalTransformEndTime = fadeOutTime + end.TimeFadeIn; } - - point++; } - int excessPoints = InternalChildren.Count - point; - for (int i = 0; i < excessPoints; i++) - RemoveInternal(InternalChildren[^1]); - // todo: use Expire() on FollowPoints and take lifetime from them when https://github.com/ppy/osu-framework/issues/3300 is fixed. - LifetimeStart = firstTransformStartTime ?? startTime; - LifetimeEnd = finalTransformEndTime; + Entry.LifetimeEnd = finalTransformEndTime; + } + + /// + /// Computes the fade time of follow point positioned between two hitobjects. + /// + /// The first , where follow points should originate from. + /// The second , which follow points should target. + /// The fractional distance along and at which the follow point is to be located. + /// The fade-in time of the follow point/ + /// The fade-out time of the follow point. + public static void GetFadeTimes(OsuHitObject start, OsuHitObject end, float fraction, out double fadeInTime, out double fadeOutTime) + { + double startTime = start.GetEndTime(); + double duration = end.StartTime - startTime; + + fadeOutTime = startTime + fraction * duration; + fadeInTime = fadeOutTime - PREEMPT; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs new file mode 100644 index 0000000000..a167cb2f0f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs @@ -0,0 +1,98 @@ +// 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.Performance; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections +{ + public class FollowPointLifetimeEntry : LifetimeEntry + { + public event Action Invalidated; + public readonly OsuHitObject Start; + + public FollowPointLifetimeEntry(OsuHitObject start) + { + Start = start; + LifetimeStart = Start.StartTime; + + bindEvents(); + } + + private OsuHitObject end; + + public OsuHitObject End + { + get => end; + set + { + UnbindEvents(); + + end = value; + + bindEvents(); + + refreshLifetimes(); + } + } + + private void bindEvents() + { + UnbindEvents(); + + // Note: Positions are bound for instantaneous feedback from positional changes from the editor, before ApplyDefaults() is called on hitobjects. + Start.DefaultsApplied += onDefaultsApplied; + Start.PositionBindable.ValueChanged += onPositionChanged; + + if (End != null) + { + End.DefaultsApplied += onDefaultsApplied; + End.PositionBindable.ValueChanged += onPositionChanged; + } + } + + public void UnbindEvents() + { + if (Start != null) + { + Start.DefaultsApplied -= onDefaultsApplied; + Start.PositionBindable.ValueChanged -= onPositionChanged; + } + + if (End != null) + { + End.DefaultsApplied -= onDefaultsApplied; + End.PositionBindable.ValueChanged -= onPositionChanged; + } + } + + private void onDefaultsApplied(HitObject obj) => refreshLifetimes(); + + private void onPositionChanged(ValueChangedEvent obj) => refreshLifetimes(); + + private void refreshLifetimes() + { + if (End == null || End.NewCombo || Start is Spinner || End is Spinner) + { + LifetimeEnd = LifetimeStart; + return; + } + + Vector2 startPosition = Start.StackedEndPosition; + Vector2 endPosition = End.StackedPosition; + Vector2 distanceVector = endPosition - startPosition; + + // The lifetime start will match the fade-in time of the first follow point. + float fraction = (int)(FollowPointConnection.SPACING * 1.5) / distanceVector.Length; + FollowPointConnection.GetFadeTimes(Start, End, fraction, out var fadeInTime, out _); + + LifetimeStart = fadeInTime; + LifetimeEnd = double.MaxValue; // This will be set by the connection. + + Invalidated?.Invoke(); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index be1392d7c3..3e85e528e8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -2,53 +2,74 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Performance; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { /// /// Visualises connections between s. /// - public class FollowPointRenderer : LifetimeManagementContainer + public class FollowPointRenderer : CompositeDrawable { - /// - /// All the s contained by this . - /// - internal IReadOnlyList Connections => connections; - - private readonly List connections = new List(); - public override bool RemoveCompletedTransforms => false; - /// - /// Adds the s around an . - /// This includes s leading into , and s exiting . - /// - /// The to add s for. - public void AddFollowPoints(OsuHitObject hitObject) - => addConnection(new FollowPointConnection(hitObject).With(g => g.StartTime.BindValueChanged(_ => onStartTimeChanged(g)))); + public IReadOnlyList Entries => lifetimeEntries; - /// - /// Removes the s around an . - /// This includes s leading into , and s exiting . - /// - /// The to remove s for. - public void RemoveFollowPoints(OsuHitObject hitObject) => removeGroup(connections.Single(g => g.Start == hitObject)); + private DrawablePool connectionPool; + private DrawablePool pointPool; - /// - /// Adds a to this . - /// - /// The to add. - /// The index of in . - private void addConnection(FollowPointConnection connection) + private readonly List lifetimeEntries = new List(); + private readonly Dictionary connectionsInUse = new Dictionary(); + private readonly Dictionary startTimeMap = new Dictionary(); + private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); + + public FollowPointRenderer() { - // Groups are sorted by their start time when added such that the index can be used to post-process other surrounding connections - int index = connections.AddInPlace(connection, Comparer.Create((g1, g2) => + lifetimeManager.EntryBecameAlive += onEntryBecameAlive; + lifetimeManager.EntryBecameDead += onEntryBecameDead; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] { - int comp = g1.StartTime.Value.CompareTo(g2.StartTime.Value); + connectionPool = new DrawablePoolNoLifetime(1, 200), + pointPool = new DrawablePoolNoLifetime(50, 1000) + }; + } + + public void AddFollowPoints(OsuHitObject hitObject) + { + addEntry(hitObject); + + var startTimeBindable = hitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindable.ValueChanged += _ => onStartTimeChanged(hitObject); + startTimeMap[hitObject] = startTimeBindable; + } + + public void RemoveFollowPoints(OsuHitObject hitObject) + { + removeEntry(hitObject); + + startTimeMap[hitObject].UnbindAll(); + startTimeMap.Remove(hitObject); + } + + private void addEntry(OsuHitObject hitObject) + { + var newEntry = new FollowPointLifetimeEntry(hitObject); + + var index = lifetimeEntries.AddInPlace(newEntry, Comparer.Create((e1, e2) => + { + int comp = e1.Start.StartTime.CompareTo(e2.Start.StartTime); if (comp != 0) return comp; @@ -61,19 +82,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections return -1; })); - if (index < connections.Count - 1) + if (index < lifetimeEntries.Count - 1) { // Update the connection's end point to the next connection's start point // h1 -> -> -> h2 // connection nextGroup - FollowPointConnection nextConnection = connections[index + 1]; - connection.End = nextConnection.Start; + FollowPointLifetimeEntry nextEntry = lifetimeEntries[index + 1]; + newEntry.End = nextEntry.Start; } else { // The end point may be non-null during re-ordering - connection.End = null; + newEntry.End = null; } if (index > 0) @@ -82,23 +103,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections // h1 -> -> -> h2 // prevGroup connection - FollowPointConnection previousConnection = connections[index - 1]; - previousConnection.End = connection.Start; + FollowPointLifetimeEntry previousEntry = lifetimeEntries[index - 1]; + previousEntry.End = newEntry.Start; } - AddInternal(connection); + lifetimeManager.AddEntry(newEntry); } - /// - /// Removes a from this . - /// - /// The to remove. - /// Whether was removed. - private void removeGroup(FollowPointConnection connection) + private void removeEntry(OsuHitObject hitObject) { - RemoveInternal(connection); + int index = lifetimeEntries.FindIndex(e => e.Start == hitObject); - int index = connections.IndexOf(connection); + var entry = lifetimeEntries[index]; + entry.UnbindEvents(); + + lifetimeEntries.RemoveAt(index); + lifetimeManager.RemoveEntry(entry); if (index > 0) { @@ -106,18 +126,61 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections // h1 -> -> -> h2 -> -> -> h3 // prevGroup connection nextGroup // The current connection's end point is used since there may not be a next connection - FollowPointConnection previousConnection = connections[index - 1]; - previousConnection.End = connection.End; + FollowPointLifetimeEntry previousEntry = lifetimeEntries[index - 1]; + previousEntry.End = entry.End; } - - connections.Remove(connection); } - private void onStartTimeChanged(FollowPointConnection connection) + protected override bool CheckChildrenLife() { - // Naive but can be improved if performance becomes an issue - removeGroup(connection); - addConnection(connection); + bool anyAliveChanged = base.CheckChildrenLife(); + anyAliveChanged |= lifetimeManager.Update(Time.Current); + return anyAliveChanged; + } + + private void onEntryBecameAlive(LifetimeEntry entry) + { + var connection = connectionPool.Get(c => + { + c.Entry = (FollowPointLifetimeEntry)entry; + c.Pool = pointPool; + }); + + connectionsInUse[entry] = connection; + + AddInternal(connection); + } + + private void onEntryBecameDead(LifetimeEntry entry) + { + RemoveInternal(connectionsInUse[entry]); + connectionsInUse.Remove(entry); + } + + private void onStartTimeChanged(OsuHitObject hitObject) + { + removeEntry(hitObject); + addEntry(hitObject); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + foreach (var entry in lifetimeEntries) + entry.UnbindEvents(); + lifetimeEntries.Clear(); + } + + private class DrawablePoolNoLifetime : DrawablePool + where T : PoolableDrawable, new() + { + public override bool RemoveWhenNotAlive => false; + + public DrawablePoolNoLifetime(int initialSize, int? maximumSize = null) + : base(initialSize, maximumSize) + { + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 2e63160d36..abb51ae420 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -19,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableHitCircle : DrawableOsuHitObject, IDrawableHitObjectWithProxiedApproach + public class DrawableHitCircle : DrawableOsuHitObject { public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; @@ -180,6 +180,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables this.Delay(800).FadeOut(); break; } + + Expire(); } public Drawable ProxiedLayer => ApproachCircle; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index bcaf73d34f..628d95dff4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Graphics.Containers; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.UI; using osuTK; @@ -52,9 +51,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } - protected override void OnApply(HitObject hitObject) + protected override void OnApply() { - base.OnApply(hitObject); + base.OnApply(); IndexInCurrentComboBindable.BindTo(HitObject.IndexInCurrentComboBindable); PositionBindable.BindTo(HitObject.PositionBindable); @@ -62,9 +61,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.BindTo(HitObject.ScaleBindable); } - protected override void OnFree(HitObject hitObject) + protected override void OnFree() { - base.OnFree(hitObject); + base.OnFree(); IndexInCurrentComboBindable.UnbindFrom(HitObject.IndexInCurrentComboBindable); PositionBindable.UnbindFrom(HitObject.PositionBindable); @@ -85,14 +84,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public virtual void Shake(double maximumLength) => shakeContainer.Shake(maximumLength); - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - // Manually set to reduce the number of future alive objects to a bare minimum. - LifetimeStart = HitObject.StartTime - HitObject.TimePreempt; - } - /// /// Causes this to get missed, disregarding all conditions in implementations of . /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 98898ce1b4..13f5960bd4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -4,9 +4,9 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; -using osuTK; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -17,15 +17,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [Resolved] private OsuConfigManager config { get; set; } - public DrawableOsuJudgement(JudgementResult result, DrawableHitObject judgedObject) - : base(result, judgedObject) - { - } - - public DrawableOsuJudgement() - { - } - [BackgroundDependencyLoader] private void load() { @@ -39,48 +30,55 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } - public override void Apply(JudgementResult result, DrawableHitObject judgedObject) - { - base.Apply(result, judgedObject); - - if (judgedObject?.HitObject is OsuHitObject osuObject) - { - Position = osuObject.StackedPosition; - Scale = new Vector2(osuObject.Scale); - } - } - protected override void PrepareForUse() { base.PrepareForUse(); Lighting.ResetAnimation(); Lighting.SetColourFrom(JudgedObject, Result); - } - private double fadeOutDelay; - protected override double FadeOutDelay => fadeOutDelay; + if (JudgedObject?.HitObject is OsuHitObject osuObject) + { + Position = osuObject.StackedPosition; + Scale = new Vector2(osuObject.Scale); + } + } protected override void ApplyHitAnimations() { bool hitLightingEnabled = config.Get(OsuSetting.HitLighting); - if (hitLightingEnabled) - { - JudgementBody.FadeIn().Delay(FadeInDuration).FadeOut(400); + Lighting.Alpha = 0; + if (hitLightingEnabled && Lighting.Drawable != null) + { + // todo: this animation changes slightly based on new/old legacy skin versions. Lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); Lighting.FadeIn(200).Then().Delay(200).FadeOut(1000); - } - else - { - JudgementBody.Alpha = 1; + + // extend the lifetime to cover lighting fade + LifetimeEnd = Lighting.LatestTransformEndTime; } - fadeOutDelay = hitLightingEnabled ? 1400 : base.FadeOutDelay; - - JudgementText?.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); base.ApplyHitAnimations(); } + + protected override Drawable CreateDefaultJudgement(HitResult result) => new OsuJudgementPiece(result); + + private class OsuJudgementPiece : DefaultJudgementPiece + { + public OsuJudgementPiece(HitResult result) + : base(result) + { + } + + public override void PlayAnimation() + { + base.PlayAnimation(); + + if (Result != HitResult.Miss) + JudgementText.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuPool.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuPool.cs deleted file mode 100644 index 1b5fd50022..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuPool.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Pooling; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Osu.Objects.Drawables -{ - public class DrawableOsuPool : DrawablePool - where T : DrawableHitObject, new() - { - private readonly Func checkHittable; - private readonly Action onLoaded; - - public DrawableOsuPool(Func checkHittable, Action onLoaded, int initialSize, int? maximumSize = null) - : base(initialSize, maximumSize) - { - this.checkHittable = checkHittable; - this.onLoaded = onLoaded; - } - - protected override T CreateNewDrawable() => base.CreateNewDrawable().With(o => - { - var osuObject = (DrawableOsuHitObject)(object)o; - - osuObject.CheckHittable = checkHittable; - osuObject.OnLoadComplete += onLoaded; - }); - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index f7b1894058..af5b609ec8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Game.Audio; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.UI; @@ -19,7 +20,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSlider : DrawableOsuHitObject, IDrawableHitObjectWithProxiedApproach + public class DrawableSlider : DrawableOsuHitObject { public new Slider HitObject => (Slider)base.HitObject; @@ -40,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private Container tailContainer; private Container tickContainer; private Container repeatContainer; - private Container samplesContainer; + private PausableSkinnableSound slidingSample; public DrawableSlider() : this(null) @@ -69,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Alpha = 0 }, headContainer = new Container { RelativeSizeAxes = Axes.Both }, - samplesContainer = new Container { RelativeSizeAxes = Axes.Both } + slidingSample = new PausableSkinnableSound { Looping = true } }; PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); @@ -86,42 +87,35 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Tracking.BindValueChanged(updateSlidingSample); } - protected override void OnApply(HitObject hitObject) + protected override void OnApply() { - base.OnApply(hitObject); + base.OnApply(); // Ensure that the version will change after the upcoming BindTo(). pathVersion.Value = int.MaxValue; PathVersion.BindTo(HitObject.Path.Version); } - protected override void OnFree(HitObject hitObject) + protected override void OnFree() { - base.OnFree(hitObject); + base.OnFree(); PathVersion.UnbindFrom(HitObject.Path.Version); - } - private PausableSkinnableSound slidingSample; + slidingSample.Samples = null; + } protected override void LoadSamples() { base.LoadSamples(); - samplesContainer.Clear(); - slidingSample = null; - var firstSample = HitObject.Samples.FirstOrDefault(); if (firstSample != null) { - var clone = HitObject.SampleControlPoint.ApplyTo(firstSample); - clone.Name = "sliderslide"; + var clone = HitObject.SampleControlPoint.ApplyTo(firstSample).With("sliderslide"); - samplesContainer.Add(slidingSample = new PausableSkinnableSound(clone) - { - Looping = true - }); + slidingSample.Samples = new ISampleInfo[] { clone }; } } @@ -193,13 +187,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return base.CreateNestedHitObject(hitObject); } - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - Body.FadeInFromZero(HitObject.TimeFadeIn); - } - public readonly Bindable Tracking = new Bindable(); protected override void Update() @@ -262,7 +249,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (userTriggered || Time.Current < HitObject.EndTime) return; - ApplyResult(r => r.Type = r.Judgement.MaxResult); + ApplyResult(r => r.Type = NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult); } public override void PlaySamples() @@ -273,6 +260,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.PlaySamples(); } + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + Body.FadeInFromZero(HitObject.TimeFadeIn); + } + protected override void UpdateStartTimeStateTransforms() { base.UpdateStartTimeStateTransforms(); @@ -294,14 +288,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { case ArmedState.Hit: Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); + if (sliderBody?.SnakingOut.Value == true) + Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear. break; } - this.FadeOut(fade_out_time, Easing.OutQuint); + this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); } - public Drawable ProxiedLayer => HeadCircle.ProxiedLayer; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => sliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); private class DefaultSliderBody : PlaySliderBody diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index fd0f35d20d..acc95ab036 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -2,24 +2,25 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSliderHead : DrawableHitCircle { + [CanBeNull] + public Slider Slider => DrawableSlider?.HitObject; + + protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + private readonly IBindable pathVersion = new Bindable(); protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle; - private DrawableSlider drawableSlider; - - private Slider slider => drawableSlider?.HitObject; - public DrawableSliderHead() { } @@ -36,34 +37,34 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables pathVersion.BindValueChanged(_ => updatePosition()); } - protected override void OnFree(HitObject hitObject) + protected override void OnFree() { - base.OnFree(hitObject); + base.OnFree(); - pathVersion.UnbindFrom(drawableSlider.PathVersion); + pathVersion.UnbindFrom(DrawableSlider.PathVersion); } - protected override void OnParentReceived(DrawableHitObject parent) + protected override void OnApply() { - base.OnParentReceived(parent); + base.OnApply(); - drawableSlider = (DrawableSlider)parent; + pathVersion.BindTo(DrawableSlider.PathVersion); - pathVersion.BindTo(drawableSlider.PathVersion); - - OnShake = drawableSlider.Shake; - CheckHittable = (d, t) => drawableSlider.CheckHittable?.Invoke(d, t) ?? true; + OnShake = DrawableSlider.Shake; + CheckHittable = (d, t) => DrawableSlider.CheckHittable?.Invoke(d, t) ?? true; } protected override void Update() { base.Update(); - double completionProgress = Math.Clamp((Time.Current - slider.StartTime) / slider.Duration, 0, 1); + Debug.Assert(Slider != null); + + double completionProgress = Math.Clamp((Time.Current - Slider.StartTime) / Slider.Duration, 0, 1); //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. if (!IsHit) - Position = slider.CurvePositionAt(completionProgress); + Position = Slider.CurvePositionAt(completionProgress); } public Action OnShake; @@ -72,8 +73,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void updatePosition() { - if (slider != null) - Position = HitObject.Position - slider.Position; + if (Slider != null) + Position = HitObject.Position - Slider.Position; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 0735d48ae1..a684df98cb 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,6 +19,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public new SliderRepeat HitObject => (SliderRepeat)base.HitObject; + [CanBeNull] + public Slider Slider => DrawableSlider?.HitObject; + + protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + private double animDuration; public Drawable CirclePiece { get; private set; } @@ -26,8 +32,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override bool DisplayResult => false; - private DrawableSlider drawableSlider; - public DrawableSliderRepeat() : base(null) { @@ -60,19 +64,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } - protected override void OnParentReceived(DrawableHitObject parent) + protected override void OnApply() { - base.OnParentReceived(parent); + base.OnApply(); - drawableSlider = (DrawableSlider)parent; - - Position = HitObject.Position - drawableSlider.Position; + Position = HitObject.Position - DrawableSlider.Position; } protected override void CheckForResult(bool userTriggered, double timeOffset) { if (HitObject.StartTime <= Time.Current) - ApplyResult(r => r.Type = drawableSlider.Tracking.Value ? r.Judgement.MaxResult : r.Judgement.MinResult); + ApplyResult(r => r.Type = DrawableSlider.Tracking.Value ? r.Judgement.MaxResult : r.Judgement.MinResult); } protected override void UpdateInitialTransforms() @@ -114,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (IsHit) return; bool isRepeatAtEnd = HitObject.RepeatIndex % 2 == 0; - List curve = ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve; + List curve = ((PlaySliderBody)DrawableSlider.Body.Drawable).CurrentCurve; Position = isRepeatAtEnd ? end : start; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index eff72168ee..6a8e02e886 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,6 +16,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject; + [CanBeNull] + public Slider Slider => DrawableSlider?.HitObject; + + protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + /// /// The judgement text is provided by the . /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index faccf5d4d1..c7bfdb02fb 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override bool DisplayResult => false; + protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; + private SkinnableDrawable scaleContainer; public DrawableSliderTick() @@ -62,11 +64,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } - protected override void OnParentReceived(DrawableHitObject parent) + protected override void OnApply() { - base.OnParentReceived(parent); + base.OnApply(); - Position = HitObject.Position - ((DrawableSlider)parent).HitObject.Position; + Position = HitObject.Position - DrawableSlider.HitObject.Position; } protected override void CheckForResult(bool userTriggered, double timeOffset) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 87c7146a64..aea37acf6f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -9,6 +9,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -33,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private Container ticks; private SpinnerBonusDisplay bonusDisplay; - private Container samplesContainer; + private PausableSkinnableSound spinningSample; private Bindable isSpinning; private bool spinnerFrequencyModulate; @@ -81,7 +82,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre, Y = -120, }, - samplesContainer = new Container { RelativeSizeAxes = Axes.Both } + spinningSample = new PausableSkinnableSound + { + Volume = { Value = 0 }, + Looping = true, + Frequency = { Value = spinning_sample_initial_frequency } + } }; PositionBindable.BindValueChanged(pos => Position = pos.NewValue); @@ -95,30 +101,28 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables isSpinning.BindValueChanged(updateSpinningSample); } - private PausableSkinnableSound spinningSample; private const float spinning_sample_initial_frequency = 1.0f; private const float spinning_sample_modulated_base_frequency = 0.5f; + protected override void OnFree() + { + base.OnFree(); + + spinningSample.Samples = null; + } + protected override void LoadSamples() { base.LoadSamples(); - samplesContainer.Clear(); - spinningSample = null; - var firstSample = HitObject.Samples.FirstOrDefault(); if (firstSample != null) { - var clone = HitObject.SampleControlPoint.ApplyTo(firstSample); - clone.Name = "spinnerspin"; + var clone = HitObject.SampleControlPoint.ApplyTo(firstSample).With("spinnerspin"); - samplesContainer.Add(spinningSample = new PausableSkinnableSound(clone) - { - Volume = { Value = 0 }, - Looping = true, - Frequency = { Value = spinning_sample_initial_frequency } - }); + spinningSample.Samples = new ISampleInfo[] { clone }; + spinningSample.Frequency.Value = spinning_sample_initial_frequency; } } @@ -157,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateHitStateTransforms(state); - this.FadeOut(160); + this.FadeOut(160).Expire(); // skin change does a rewind of transforms, which will stop the spinning sound from playing if it's currently in playback. isSpinning?.TriggerChange(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs index fc9a7c00e6..726fbd3ea6 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs @@ -7,6 +7,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public override bool DisplayResult => false; + protected DrawableSpinner DrawableSpinner => (DrawableSpinner)ParentHitObject; + public DrawableSpinnerTick() : base(null) { @@ -17,6 +19,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } + protected override double MaximumJudgementOffset => DrawableSpinner.HitObject.Duration; + /// /// Apply a judgement result. /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs index c455c66e8d..d0e1055dce 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs @@ -13,6 +13,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class CirclePiece : CompositeDrawable { + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + private TrianglesPiece triangles; + public CirclePiece() { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); @@ -26,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } [BackgroundDependencyLoader] - private void load(TextureStore textures, DrawableHitObject drawableHitObject) + private void load(TextureStore textures) { InternalChildren = new Drawable[] { @@ -36,13 +41,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Origin = Anchor.Centre, Texture = textures.Get(@"Gameplay/osu/disc"), }, - new TrianglesPiece(drawableHitObject.GetHashCode()) + triangles = new TrianglesPiece { RelativeSizeAxes = Axes.Both, Blending = BlendingParameters.Additive, Alpha = 0.5f, } }; + + drawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableObject); + } + + private void onHitObjectApplied(DrawableHitObject obj) + { + if (obj.HitObject == null) + return; + + triangles.Reset((int)obj.HitObject.StartTime); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= onHitObjectApplied; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs index 6381ddca69..09299a3622 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs @@ -1,14 +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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class ExplodePiece : Container { + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + private TrianglesPiece triangles; + public ExplodePiece() { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); @@ -18,13 +25,36 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Blending = BlendingParameters.Additive; Alpha = 0; + } - Child = new TrianglesPiece + [BackgroundDependencyLoader] + private void load() + { + Child = triangles = new TrianglesPiece { Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, Alpha = 0.2f, }; + + drawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableObject); + } + + private void onHitObjectApplied(DrawableHitObject obj) + { + if (obj.HitObject == null) + return; + + triangles.Reset((int)obj.HitObject.StartTime); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.HitObjectApplied -= onHitObjectApplied; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs index bf2236c945..102166f8dd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs @@ -38,7 +38,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }; } - private readonly IBindable state = new Bindable(); private readonly IBindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); @@ -50,7 +49,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { var drawableOsuObject = (DrawableOsuHitObject)drawableObject; - state.BindTo(drawableObject.State); accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); } @@ -59,7 +57,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { base.LoadComplete(); - state.BindValueChanged(updateState, true); accentColour.BindValueChanged(colour => { explode.Colour = colour.NewValue; @@ -68,15 +65,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }, true); indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + + drawableObject.ApplyCustomUpdateState += updateState; + updateState(drawableObject, drawableObject.State.Value); } - private void updateState(ValueChangedEvent state) + private void updateState(DrawableHitObject drawableObject, ArmedState state) { using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) { glow.FadeOut(400); - switch (state.NewValue) + switch (state) { case ArmedState.Hit: const double flash_in = 40; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs index c5bf790377..ca5ca7ac59 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs @@ -248,7 +248,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } private void trackingChanged(ValueChangedEvent tracking) => - box.FadeTo(tracking.NewValue ? 0.6f : 0.05f, 200, Easing.OutQuint); + box.FadeTo(tracking.NewValue ? 0.3f : 0.05f, 200, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs index 6cdb0d3df3..53dc7ecea3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs @@ -7,7 +7,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public class TrianglesPiece : Triangles { - protected override bool ExpireOffScreenTriangles => false; protected override bool CreateNewTriangles => false; protected override float SpawnRatio => 0.5f; diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 755ce0866a..1670df24a8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -221,14 +221,7 @@ namespace osu.Game.Rulesets.Osu.Objects var sampleList = new List(); if (firstSample != null) - { - sampleList.Add(new HitSampleInfo - { - Bank = firstSample.Bank, - Volume = firstSample.Volume, - Name = @"slidertick", - }); - } + sampleList.Add(firstSample.With("slidertick")); foreach (var tick in NestedHitObjects.OfType()) tick.Samples = sampleList; diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs index 235dc8710a..2c443cb96b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Objects { public SpinnerBonusTick() { - Samples.Add(new HitSampleInfo { Name = "spinnerbonus" }); + Samples.Add(new HitSampleInfo("spinnerbonus")); } public override Judgement CreateJudgement() => new OsuSpinnerBonusTickJudgement(); diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs index 1551d1c149..21af9a479e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs @@ -38,7 +38,6 @@ namespace osu.Game.Rulesets.Osu.Skinning private SkinnableSpriteText hitCircleText; - private readonly IBindable state = new Bindable(); private readonly Bindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); @@ -113,7 +112,6 @@ namespace osu.Game.Rulesets.Osu.Skinning if (overlayAboveNumber) AddInternal(hitCircleOverlay.CreateProxy()); - state.BindTo(drawableObject.State); accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); @@ -137,19 +135,21 @@ namespace osu.Game.Rulesets.Osu.Skinning { base.LoadComplete(); - state.BindValueChanged(updateState, true); accentColour.BindValueChanged(colour => hitCircleSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); if (hasNumber) indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); + + drawableObject.ApplyCustomUpdateState += updateState; + updateState(drawableObject, drawableObject.State.Value); } - private void updateState(ValueChangedEvent state) + private void updateState(DrawableHitObject drawableObject, ArmedState state) { const double legacy_fade_duration = 240; using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) { - switch (state.NewValue) + switch (state) { case ArmedState.Hit: circleSprites.FadeOut(legacy_fade_duration, Easing.Out); diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index c816502d61..975b444699 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -20,15 +20,12 @@ using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.UI { public class OsuPlayfield : Playfield { - public readonly Func CheckHittable; - private readonly PlayfieldBorder playfieldBorder; private readonly ProxyContainer approachCircles; private readonly ProxyContainer spinnerProxies; @@ -40,86 +37,81 @@ namespace osu.Game.Rulesets.Osu.UI protected override GameplayCursorContainer CreateCursor() => new OsuCursorContainer(); - private readonly Bindable playfieldBorderStyle = new BindableBool(); - private readonly IDictionary> poolDictionary = new Dictionary>(); + private readonly Container judgementAboveHitObjectLayer; + public OsuPlayfield() { InternalChildren = new Drawable[] { - playfieldBorder = new PlayfieldBorder - { - RelativeSizeAxes = Axes.Both, - Depth = 3 - }, - spinnerProxies = new ProxyContainer - { - RelativeSizeAxes = Axes.Both - }, - followPoints = new FollowPointRenderer - { - RelativeSizeAxes = Axes.Both, - Depth = 2, - }, - judgementLayer = new JudgementContainer - { - RelativeSizeAxes = Axes.Both, - Depth = 1, - }, - // Todo: This should not exist, but currently helps to reduce LOH allocations due to unbinding skin source events on judgement disposal - // Todo: Remove when hitobjects are properly pooled - new SkinProvidingContainer(null) - { - Child = HitObjectContainer, - }, - approachCircles = new ProxyContainer - { - RelativeSizeAxes = Axes.Both, - Depth = -1, - }, + playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + spinnerProxies = new ProxyContainer { RelativeSizeAxes = Axes.Both }, + followPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }, + judgementLayer = new JudgementContainer { RelativeSizeAxes = Axes.Both }, + HitObjectContainer, + judgementAboveHitObjectLayer = new Container { RelativeSizeAxes = Axes.Both }, + approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both }, }; hitPolicy = new OrderedHitPolicy(HitObjectContainer); - CheckHittable = hitPolicy.IsHittable; var hitWindows = new OsuHitWindows(); foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) - poolDictionary.Add(result, new DrawableJudgementPool(result)); + poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgmentLoaded)); AddRangeInternal(poolDictionary.Values); NewResult += onNewResult; } + protected override void OnNewDrawableHitObject(DrawableHitObject drawable) + { + ((DrawableOsuHitObject)drawable).CheckHittable = hitPolicy.IsHittable; + + Debug.Assert(!drawable.IsLoaded, $"Already loaded {nameof(DrawableHitObject)} is added to {nameof(OsuPlayfield)}"); + drawable.OnLoadComplete += onDrawableHitObjectLoaded; + } + + private void onDrawableHitObjectLoaded(Drawable drawable) + { + // note: `Slider`'s `ProxiedLayer` is added when its nested `DrawableHitCircle` is loaded. + switch (drawable) + { + case DrawableSpinner _: + spinnerProxies.Add(drawable.CreateProxy()); + break; + + case DrawableHitCircle hitCircle: + approachCircles.Add(hitCircle.ProxiedLayer.CreateProxy()); + break; + } + } + + private void onJudgmentLoaded(DrawableOsuJudgement judgement) + { + judgementAboveHitObjectLayer.Add(judgement.GetProxyAboveHitObjectsContent()); + } + [BackgroundDependencyLoader(true)] private void load(OsuRulesetConfigManager config) { config?.BindWith(OsuRulesetSetting.PlayfieldBorderStyle, playfieldBorder.PlayfieldBorderStyle); - registerPool(10, 100); + RegisterPool(10, 100); - registerPool(10, 100); - registerPool(10, 100); - registerPool(10, 100); - registerPool(10, 100); - registerPool(5, 50); + RegisterPool(10, 100); + RegisterPool(10, 100); + RegisterPool(10, 100); + RegisterPool(10, 100); + RegisterPool(5, 50); - registerPool(2, 20); - registerPool(10, 100); - registerPool(10, 100); + RegisterPool(2, 20); + RegisterPool(10, 100); + RegisterPool(10, 100); } - private void registerPool(int initialSize, int? maximumSize = null) - where TObject : HitObject - where TDrawable : DrawableHitObject, new() - => RegisterPool(CreatePool(initialSize, maximumSize)); - - protected virtual DrawablePool CreatePool(int initialSize, int? maximumSize = null) - where TDrawable : DrawableHitObject, new() - => new DrawableOsuPool(CheckHittable, OnHitObjectLoaded, initialSize, maximumSize); - protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new OsuHitObjectLifetimeEntry(hitObject); protected override void OnHitObjectAdded(HitObject hitObject) @@ -134,27 +126,6 @@ namespace osu.Game.Rulesets.Osu.UI followPoints.RemoveFollowPoints((OsuHitObject)hitObject); } - public void OnHitObjectLoaded(Drawable drawable) - { - switch (drawable) - { - case DrawableSliderHead _: - case DrawableSliderTail _: - case DrawableSliderTick _: - case DrawableSliderRepeat _: - case DrawableSpinnerTick _: - break; - - case DrawableSpinner _: - spinnerProxies.Add(drawable.CreateProxy()); - break; - - case IDrawableHitObjectWithProxiedApproach approach: - approachCircles.Add(approach.ProxiedLayer.CreateProxy()); - break; - } - } - private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) { // Hitobjects that block future hits should miss previous hitobjects if they're hit out-of-order. @@ -178,11 +149,13 @@ namespace osu.Game.Rulesets.Osu.UI private class DrawableJudgementPool : DrawablePool { private readonly HitResult result; + private readonly Action onLoaded; - public DrawableJudgementPool(HitResult result) + public DrawableJudgementPool(HitResult result, Action onLoaded) : base(10) { this.result = result; + this.onLoaded = onLoaded; } protected override DrawableOsuJudgement CreateNewDrawable() @@ -192,6 +165,8 @@ namespace osu.Game.Rulesets.Osu.UI // just a placeholder to initialise the correct drawable hierarchy for this pool. judgement.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null); + onLoaded?.Invoke(judgement); + return judgement; } } @@ -201,6 +176,8 @@ namespace osu.Game.Rulesets.Osu.UI public OsuHitObjectLifetimeEntry(HitObject hitObject) : base(hitObject) { + // Prevent past objects in idles states from remaining alive as their end times are skipped in non-frame-stable contexts. + LifetimeEnd = HitObject.GetEndTime() + HitObject.HitWindows.WindowFor(HitResult.Miss); } protected override double InitialLifetimeOffset => ((OsuHitObject)HitObject).TimePreempt; 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 b59f3a4344..a89645d881 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 @@ -2,7 +2,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index c5191ab241..17e7fb81f6 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -43,10 +43,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints return false; } - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { piece.Position = ToLocalSpace(result.ScreenSpacePosition); - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index 468d980b23..e53b331f46 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -68,9 +68,9 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints EndPlacement(true); } - public override void UpdatePosition(SnapResult result) + public override void UpdateTimeAndPosition(SnapResult result) { - base.UpdatePosition(result); + base.UpdateTimeAndPosition(result); if (PlacementActive) { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs index c51b47dc6e..d1ad4c9d8d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs @@ -7,6 +7,6 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModEasy : ModEasy { - public override string Description => @"Beats move slower, less accuracy required, and three lives!"; + public override string Description => @"Beats move slower, and less accuracy required!"; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index bf44a80037..be659f6ca5 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Filled = HitObject.FirstTick }); + protected override double MaximumJudgementOffset => HitObject.HitWindow; + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (!userTriggered) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 4a3759794b..29a96a7a40 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -79,7 +79,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (isRimType != rimSamples.Any()) { if (isRimType) - HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP }); + HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); else { foreach (var sample in rimSamples) @@ -125,9 +125,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (s.Name != HitSampleInfo.HIT_FINISH) continue; - var sClone = s.Clone(); - sClone.Name = HitSampleInfo.HIT_WHISTLE; - corrected[i] = sClone; + corrected[i] = s.With(HitSampleInfo.HIT_WHISTLE); } return corrected; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index d8d75a7614..ff5b221273 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (isStrong.Value != strongSamples.Any()) { if (isStrong.Value) - HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH }); + HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); else { foreach (var sample in strongSamples) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index cbfc5a8628..b5e35f88b5 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -1,12 +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 osu.Game.Rulesets.Objects.Drawables; -using osu.Framework.Allocation; -using osu.Game.Graphics; -using osu.Game.Rulesets.Judgements; using osu.Framework.Graphics; -using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.UI { @@ -25,21 +22,6 @@ namespace osu.Game.Rulesets.Taiko.UI { } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - switch (Result.Type) - { - case HitResult.Ok: - JudgementBody.Colour = colours.GreenLight; - break; - - case HitResult.Great: - JudgementBody.Colour = colours.BlueLight; - break; - } - } - protected override void ApplyHitAnimations() { this.MoveToY(-100, 500); diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 120cf264c3..370760f03e 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -37,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.UI private SkinnableDrawable mascot; private ProxyContainer topLevelHitContainer; - private ProxyContainer barlineContainer; + private ScrollingHitObjectContainer barlineContainer; private Container rightArea; private Container leftArea; @@ -83,10 +84,7 @@ namespace osu.Game.Rulesets.Taiko.UI RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - barlineContainer = new ProxyContainer - { - RelativeSizeAxes = Axes.Both, - }, + barlineContainer = new ScrollingHitObjectContainer(), new Container { Name = "Hit objects", @@ -159,18 +157,37 @@ namespace osu.Game.Rulesets.Taiko.UI public override void Add(DrawableHitObject h) { - h.OnNewResult += OnNewResult; - base.Add(h); - switch (h) { case DrawableBarLine barline: - barlineContainer.Add(barline.CreateProxy()); + barlineContainer.Add(barline); break; case DrawableTaikoHitObject taikoObject: + h.OnNewResult += OnNewResult; topLevelHitContainer.Add(taikoObject.CreateProxiedContent()); + base.Add(h); break; + + default: + throw new ArgumentException($"Unsupported {nameof(DrawableHitObject)} type"); + } + } + + public override bool Remove(DrawableHitObject h) + { + switch (h) + { + case DrawableBarLine barline: + return barlineContainer.Remove(barline); + + case DrawableTaikoHitObject _: + h.OnNewResult -= OnNewResult; + // todo: consider tidying of proxied content if required. + return base.Remove(h); + + default: + throw new ArgumentException($"Unsupported {nameof(DrawableHitObject)} type"); } } diff --git a/osu.Game.Tests/Audio/SampleInfoEqualityTest.cs b/osu.Game.Tests/Audio/SampleInfoEqualityTest.cs new file mode 100644 index 0000000000..149096608f --- /dev/null +++ b/osu.Game.Tests/Audio/SampleInfoEqualityTest.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Audio; + +namespace osu.Game.Tests.Audio +{ + [TestFixture] + public class SampleInfoEqualityTest + { + [Test] + public void TestSameSingleSamplesAreEqual() + { + var first = new SampleInfo("sample"); + var second = new SampleInfo("sample"); + + assertEquality(first, second); + } + + [Test] + public void TestDifferentSingleSamplesAreNotEqual() + { + var first = new SampleInfo("first"); + var second = new SampleInfo("second"); + + assertNonEquality(first, second); + } + + [Test] + public void TestDifferentCountSampleSetsAreNotEqual() + { + var first = new SampleInfo("sample", "extra"); + var second = new SampleInfo("sample"); + + assertNonEquality(first, second); + } + + [Test] + public void TestDifferentSampleSetsOfSameCountAreNotEqual() + { + var first = new SampleInfo("first", "common"); + var second = new SampleInfo("common", "second"); + + assertNonEquality(first, second); + } + + [Test] + public void TestSameOrderSameSampleSetsAreEqual() + { + var first = new SampleInfo("first", "second"); + var second = new SampleInfo("first", "second"); + + assertEquality(first, second); + } + + [Test] + public void TestDifferentOrderSameSampleSetsAreEqual() + { + var first = new SampleInfo("first", "second"); + var second = new SampleInfo("second", "first"); + + assertEquality(first, second); + } + + private void assertEquality(SampleInfo first, SampleInfo second) + { + Assert.That(first.Equals(second), Is.True); + Assert.That(first.GetHashCode(), Is.EqualTo(second.GetHashCode())); + } + + private void assertNonEquality(SampleInfo first, SampleInfo second) + { + Assert.That(first.Equals(second), Is.False); + Assert.That(first.GetHashCode(), Is.Not.EqualTo(second.GetHashCode())); + } + } +} diff --git a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs index b7a41ffd1c..481cb3230e 100644 --- a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs +++ b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; @@ -44,6 +43,36 @@ namespace osu.Game.Tests.Editing Assert.That(stateChangedFired, Is.EqualTo(2)); } + [Test] + public void TestApplyThenUndoThenApplySameChange() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.False); + + string originalHash = handler.CurrentStateHash; + + addArbitraryChange(beatmap); + handler.SaveState(); + + Assert.That(handler.CanUndo.Value, Is.True); + Assert.That(handler.CanRedo.Value, Is.False); + Assert.That(stateChangedFired, Is.EqualTo(1)); + + string hash = handler.CurrentStateHash; + + // undo a change without saving + handler.RestoreState(-1); + + Assert.That(originalHash, Is.EqualTo(handler.CurrentStateHash)); + Assert.That(stateChangedFired, Is.EqualTo(2)); + + addArbitraryChange(beatmap); + handler.SaveState(); + Assert.That(hash, Is.EqualTo(handler.CurrentStateHash)); + } + [Test] public void TestSaveSameStateDoesNotSave() { @@ -139,7 +168,7 @@ namespace osu.Game.Tests.Editing private void addArbitraryChange(EditorBeatmap beatmap) { - beatmap.Add(new HitCircle { StartTime = RNG.Next(0, 100000) }); + beatmap.Add(new HitCircle { StartTime = 2760 }); } } } diff --git a/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs index bb56131b04..44a908b756 100644 --- a/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs +++ b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs @@ -139,7 +139,7 @@ namespace osu.Game.Tests.Editing HitObjects = { (OsuHitObject)current.HitObjects[0], - new HitCircle { StartTime = 2000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } }, + new HitCircle { StartTime = 2000, Samples = { new HitSampleInfo(HitSampleInfo.HIT_FINISH) } }, (OsuHitObject)current.HitObjects[2], } }; @@ -268,12 +268,12 @@ namespace osu.Game.Tests.Editing HitObjects = { (OsuHitObject)current.HitObjects[0], - new HitCircle { StartTime = 1000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } }, + new HitCircle { StartTime = 1000, Samples = { new HitSampleInfo(HitSampleInfo.HIT_FINISH) } }, (OsuHitObject)current.HitObjects[2], (OsuHitObject)current.HitObjects[3], - new HitCircle { StartTime = 2250, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE } } }, + new HitCircle { StartTime = 2250, Samples = { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE) } }, (OsuHitObject)current.HitObjects[5], - new HitCircle { StartTime = 3000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP } } }, + new HitCircle { StartTime = 3000, Samples = { new HitSampleInfo(HitSampleInfo.HIT_CLAP) } }, (OsuHitObject)current.HitObjects[7], } }; diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs new file mode 100644 index 0000000000..a5c937119e --- /dev/null +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.NonVisual.Skinning +{ + [HeadlessTest] + public class LegacySkinAnimationTest : OsuTestScene + { + private const string animation_name = "animation"; + private const int frame_count = 6; + + [Cached(typeof(IAnimationTimeReference))] + private TestAnimationTimeReference animationTimeReference = new TestAnimationTimeReference(); + + private TextureAnimation animation; + + [Test] + public void TestAnimationTimeReferenceChange() + { + ISkin skin = new TestSkin(); + + AddStep("get animation", () => Add(animation = (TextureAnimation)skin.GetAnimation(animation_name, true, false))); + AddAssert("frame count correct", () => animation.FrameCount == frame_count); + assertPlaybackPosition(0); + + AddStep("set start time to 1000", () => animationTimeReference.AnimationStartTime.Value = 1000); + assertPlaybackPosition(-1000); + + AddStep("set current time to 500", () => animationTimeReference.ManualClock.CurrentTime = 500); + assertPlaybackPosition(-500); + } + + private void assertPlaybackPosition(double expectedPosition) + => AddAssert($"playback position is {expectedPosition}", () => animation.PlaybackPosition == expectedPosition); + + private class TestSkin : ISkin + { + private static readonly string[] lookup_names = Enumerable.Range(0, frame_count).Select(frame => $"{animation_name}-{frame}").ToArray(); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + return lookup_names.Contains(componentName) ? Texture.WhitePixel : null; + } + + public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); + public IBindable GetConfig(TLookup lookup) => throw new NotSupportedException(); + } + + private class TestAnimationTimeReference : IAnimationTimeReference + { + public ManualClock ManualClock { get; } + public IFrameBasedClock Clock { get; } + public Bindable AnimationStartTime { get; } = new BindableDouble(); + + public TestAnimationTimeReference() + { + ManualClock = new ManualClock(); + Clock = new FramedClock(ManualClock); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 9ef9649f77..5323f58a66 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -318,7 +318,7 @@ namespace osu.Game.Tests.Visual.Background private class FadeAccessibleResults : ResultsScreen { public FadeAccessibleResults(ScoreInfo score) - : base(score) + : base(score, true) { } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 8190cf5f89..11830ebe35 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -153,6 +153,9 @@ namespace osu.Game.Tests.Visual.Editing private class SnapProvider : IPositionSnapProvider { + public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => + new SnapResult(screenSpacePosition, null); + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); public float GetBeatSnapDistanceAt(double referenceTime) => 10; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index 1a1babb4a8..9931ee4a45 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -21,13 +21,13 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK; using osuTK.Graphics; +using JetBrains.Annotations; namespace osu.Game.Tests.Visual.Gameplay { @@ -46,6 +46,50 @@ namespace osu.Game.Tests.Visual.Gameplay [SetUp] public void Setup() => Schedule(() => testClock.CurrentTime = 0); + [TestCase("pooled")] + [TestCase("non-pooled")] + public void TestHitObjectLifetime(string pooled) + { + var beatmap = createBeatmap(_ => pooled == "pooled" ? new TestPooledHitObject() : new TestHitObject()); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + createTest(beatmap); + + assertPosition(0, 0f); + assertDead(3); + + setTime(3 * time_range); + assertPosition(3, 0f); + assertDead(0); + + setTime(0 * time_range); + assertPosition(0, 0f); + assertDead(3); + } + + [TestCase("pooled")] + [TestCase("non-pooled")] + public void TestNestedHitObject(string pooled) + { + var beatmap = createBeatmap(i => + { + var h = pooled == "pooled" ? new TestPooledParentHitObject() : new TestParentHitObject(); + h.Duration = 300; + h.ChildTimeOffset = i % 3 * 100; + return h; + }); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + createTest(beatmap); + + assertPosition(0, 0f); + assertHeight(0); + assertChildPosition(0); + + setTime(5 * time_range); + assertPosition(5, 0f); + assertHeight(5); + assertChildPosition(5); + } + [Test] public void TestRelativeBeatLengthScaleSingleTimingPoint() { @@ -147,8 +191,37 @@ namespace osu.Game.Tests.Visual.Gameplay assertPosition(1, 1); } + /// + /// Get a corresponding to the 'th . + /// When the hit object is not alive, `null` is returned. + /// + [CanBeNull] + private DrawableTestHitObject getDrawableHitObject(int index) + { + var hitObject = drawableRuleset.Beatmap.HitObjects.ElementAt(index); + return (DrawableTestHitObject)drawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(obj => obj.HitObject == hitObject); + } + + private float yScale => drawableRuleset.Playfield.HitObjectContainer.DrawHeight; + + private void assertDead(int index) => AddAssert($"hitobject {index} is dead", () => getDrawableHitObject(index) == null); + + private void assertHeight(int index) => AddAssert($"hitobject {index} height", () => + { + var d = getDrawableHitObject(index); + return d != null && Precision.AlmostEquals(d.DrawHeight, yScale * (float)(d.HitObject.Duration / time_range), 0.1f); + }); + + private void assertChildPosition(int index) => AddAssert($"hitobject {index} child position", () => + { + var d = getDrawableHitObject(index); + return d is DrawableTestParentHitObject && Precision.AlmostEquals( + d.NestedHitObjects.First().DrawPosition.Y, + yScale * (float)((TestParentHitObject)d.HitObject).ChildTimeOffset / time_range, 0.1f); + }); + private void assertPosition(int index, float relativeY) => AddAssert($"hitobject {index} at {relativeY}", - () => Precision.AlmostEquals(drawableRuleset.Playfield.AllHitObjects.ElementAt(index).DrawPosition.Y, drawableRuleset.Playfield.HitObjectContainer.DrawHeight * relativeY)); + () => Precision.AlmostEquals(getDrawableHitObject(index)?.DrawPosition.Y ?? -1, yScale * relativeY)); private void setTime(double time) { @@ -160,12 +233,16 @@ namespace osu.Game.Tests.Visual.Gameplay /// The hitobjects are spaced milliseconds apart. /// /// The . - private IBeatmap createBeatmap() + private IBeatmap createBeatmap(Func createAction = null) { - var beatmap = new Beatmap { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } }; + var beatmap = new Beatmap { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } }; for (int i = 0; i < 10; i++) - beatmap.HitObjects.Add(new HitObject { StartTime = i * time_range }); + { + var h = createAction?.Invoke(i) ?? new TestHitObject(); + h.StartTime = i * time_range; + beatmap.HitObjects.Add(h); + } return beatmap; } @@ -225,7 +302,21 @@ namespace osu.Game.Tests.Visual.Gameplay TimeRange.Value = time_range; } - public override DrawableHitObject CreateDrawableRepresentation(TestHitObject h) => new DrawableTestHitObject(h); + public override DrawableHitObject CreateDrawableRepresentation(TestHitObject h) + { + switch (h) + { + case TestPooledHitObject _: + case TestPooledParentHitObject _: + return null; + + case TestParentHitObject p: + return new DrawableTestParentHitObject(p); + + default: + return new DrawableTestHitObject(h); + } + } protected override PassThroughInputManager CreateInputManager() => new PassThroughInputManager(); @@ -265,6 +356,9 @@ namespace osu.Game.Tests.Visual.Gameplay } } }); + + RegisterPool(1); + RegisterPool(1); } } @@ -277,30 +371,46 @@ namespace osu.Game.Tests.Visual.Gameplay public override bool CanConvert() => true; - protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) - { - yield return new TestHitObject - { - StartTime = original.StartTime, - Duration = (original as IHasDuration)?.Duration ?? 100 - }; - } + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) => + throw new NotImplementedException(); } #endregion #region HitObject - private class TestHitObject : ConvertHitObject, IHasDuration + private class TestHitObject : HitObject, IHasDuration { public double EndTime => StartTime + Duration; - public double Duration { get; set; } + public double Duration { get; set; } = 100; + } + + private class TestPooledHitObject : TestHitObject + { + } + + private class TestParentHitObject : TestHitObject + { + public double ChildTimeOffset; + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(new TestHitObject { StartTime = StartTime + ChildTimeOffset }); + } + } + + private class TestPooledParentHitObject : TestParentHitObject + { + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(new TestPooledHitObject { StartTime = StartTime + ChildTimeOffset }); + } } private class DrawableTestHitObject : DrawableHitObject { - public DrawableTestHitObject(TestHitObject hitObject) + public DrawableTestHitObject([CanBeNull] TestHitObject hitObject) : base(hitObject) { Anchor = Anchor.TopCentre; @@ -324,6 +434,52 @@ namespace osu.Game.Tests.Visual.Gameplay } }); } + + protected override void Update() => LifetimeEnd = HitObject.EndTime; + } + + private class DrawableTestPooledHitObject : DrawableTestHitObject + { + public DrawableTestPooledHitObject() + : base(null) + { + InternalChildren[0].Colour = Color4.LightSkyBlue; + InternalChildren[1].Colour = Color4.Blue; + } + } + + private class DrawableTestParentHitObject : DrawableTestHitObject + { + private readonly Container container; + + public DrawableTestParentHitObject([CanBeNull] TestHitObject hitObject) + : base(hitObject) + { + InternalChildren[0].Colour = Color4.LightYellow; + InternalChildren[1].Colour = Color4.Yellow; + + AddInternal(container = new Container + { + RelativeSizeAxes = Axes.Both, + }); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) => + new DrawableTestHitObject((TestHitObject)hitObject); + + protected override void AddNestedHitObject(DrawableHitObject hitObject) => container.Add(hitObject); + + protected override void ClearNestedHitObjects() => container.Clear(false); + } + + private class DrawableTestPooledParentHitObject : DrawableTestParentHitObject + { + public DrawableTestPooledParentHitObject() + : base(null) + { + InternalChildren[0].Colour = Color4.LightSeaGreen; + InternalChildren[1].Colour = Color4.Green; + } } #endregion diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs new file mode 100644 index 0000000000..82095cb809 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneParticleExplosion : OsuTestScene + { + private ParticleExplosion explosion; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddStep("create initial", () => + { + Child = explosion = new ParticleExplosion(textures.Get("Cursor/cursortrail"), 150, 1200) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(400) + }; + }); + + AddWaitStep("wait for playback", 5); + + AddRepeatStep(@"restart animation", () => + { + explosion.Restart(); + }, 10); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index 3e777119c4..cd7d692b0a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -261,9 +261,9 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - protected override void OnApply(HitObject hitObject) + protected override void OnApply() { - base.OnApply(hitObject); + base.OnApply(); Position = new Vector2(RNG.Next(-200, 200), RNG.Next(-200, 200)); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs index 709e71d195..717485bcc1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs @@ -3,7 +3,6 @@ using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -14,44 +13,41 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneStarCounter : OsuTestScene { + private readonly StarCounter starCounter; + private readonly OsuSpriteText starsLabel; + public TestSceneStarCounter() { - StarCounter stars = new StarCounter + starCounter = new StarCounter { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Current = 5, }; - Add(stars); + Add(starCounter); - SpriteText starsLabel = new OsuSpriteText + starsLabel = new OsuSpriteText { Origin = Anchor.Centre, Anchor = Anchor.Centre, Scale = new Vector2(2), Y = 50, - Text = stars.Current.ToString("0.00"), }; Add(starsLabel); - AddRepeatStep(@"random value", delegate - { - stars.Current = RNG.NextSingle() * (stars.StarCount + 1); - starsLabel.Text = stars.Current.ToString("0.00"); - }, 10); + setStars(5); - AddStep(@"Stop animation", delegate - { - stars.StopAnimation(); - }); + AddRepeatStep("random value", () => setStars(RNG.NextSingle() * (starCounter.StarCount + 1)), 10); + AddSliderStep("exact value", 0f, 10f, 5f, setStars); + AddStep("stop animation", () => starCounter.StopAnimation()); + AddStep("reset", () => setStars(0)); + } - AddStep(@"Reset", delegate - { - stars.Current = 0; - starsLabel.Text = stars.Current.ToString("0.00"); - }); + private void setStars(float stars) + { + starCounter.Current = stars; + starsLabel.Text = starCounter.Current.ToString("0.00"); } } } diff --git a/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs b/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs new file mode 100644 index 0000000000..cf5ecf5bf2 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs @@ -0,0 +1,181 @@ +// 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.Overlays.Profile.Sections.Historical; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Users; +using NUnit.Framework; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using System; +using System.Linq; +using osu.Framework.Testing; +using osu.Framework.Graphics.Shapes; +using static osu.Game.Users.User; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestScenePlayHistorySubsection : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red); + + private readonly Bindable user = new Bindable(); + private readonly PlayHistorySubsection section; + + public TestScenePlayHistorySubsection() + { + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + section = new PlayHistorySubsection(user) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }); + } + + [Test] + public void TestNullValues() + { + AddStep("Load user", () => user.Value = user_with_null_values); + AddAssert("Section is hidden", () => section.Alpha == 0); + } + + [Test] + public void TestEmptyValues() + { + AddStep("Load user", () => user.Value = user_with_empty_values); + AddAssert("Section is hidden", () => section.Alpha == 0); + } + + [Test] + public void TestOneValue() + { + AddStep("Load user", () => user.Value = user_with_one_value); + AddAssert("Section is hidden", () => section.Alpha == 0); + } + + [Test] + public void TestTwoValues() + { + AddStep("Load user", () => user.Value = user_with_two_values); + AddAssert("Section is visible", () => section.Alpha == 1); + } + + [Test] + public void TestConstantValues() + { + AddStep("Load user", () => user.Value = user_with_constant_values); + AddAssert("Section is visible", () => section.Alpha == 1); + } + + [Test] + public void TestConstantZeroValues() + { + AddStep("Load user", () => user.Value = user_with_zero_values); + AddAssert("Section is visible", () => section.Alpha == 1); + } + + [Test] + public void TestFilledValues() + { + AddStep("Load user", () => user.Value = user_with_filled_values); + AddAssert("Section is visible", () => section.Alpha == 1); + AddAssert("Array length is the same", () => user_with_filled_values.MonthlyPlaycounts.Length == getChartValuesLength()); + } + + [Test] + public void TestMissingValues() + { + AddStep("Load user", () => user.Value = user_with_missing_values); + AddAssert("Section is visible", () => section.Alpha == 1); + AddAssert("Array length is 7", () => getChartValuesLength() == 7); + } + + private int getChartValuesLength() => this.ChildrenOfType().Single().Values.Length; + + private static readonly User user_with_null_values = new User + { + Id = 1 + }; + + private static readonly User user_with_empty_values = new User + { + Id = 2, + MonthlyPlaycounts = Array.Empty() + }; + + private static readonly User user_with_one_value = new User + { + Id = 3, + MonthlyPlaycounts = new[] + { + new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 100 } + } + }; + + private static readonly User user_with_two_values = new User + { + Id = 4, + MonthlyPlaycounts = new[] + { + new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 1 }, + new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 2 } + } + }; + + private static readonly User user_with_constant_values = new User + { + Id = 5, + MonthlyPlaycounts = new[] + { + new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 5 }, + new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 5 }, + new UserHistoryCount { Date = new DateTime(2010, 7, 1), Count = 5 } + } + }; + + private static readonly User user_with_zero_values = new User + { + Id = 6, + MonthlyPlaycounts = new[] + { + new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 0 }, + new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 0 }, + new UserHistoryCount { Date = new DateTime(2010, 7, 1), Count = 0 } + } + }; + + private static readonly User user_with_filled_values = new User + { + Id = 7, + MonthlyPlaycounts = new[] + { + new UserHistoryCount { Date = new DateTime(2010, 5, 1), Count = 1000 }, + new UserHistoryCount { Date = new DateTime(2010, 6, 1), Count = 20 }, + new UserHistoryCount { Date = new DateTime(2010, 7, 1), Count = 20000 }, + new UserHistoryCount { Date = new DateTime(2010, 8, 1), Count = 30 }, + new UserHistoryCount { Date = new DateTime(2010, 9, 1), Count = 50 }, + new UserHistoryCount { Date = new DateTime(2010, 10, 1), Count = 2000 }, + new UserHistoryCount { Date = new DateTime(2010, 11, 1), Count = 2100 } + } + }; + + private static readonly User user_with_missing_values = new User + { + Id = 8, + MonthlyPlaycounts = new[] + { + new UserHistoryCount { Date = new DateTime(2020, 1, 1), Count = 100 }, + new UserHistoryCount { Date = new DateTime(2020, 7, 1), Count = 200 } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index ff96a999ec..b2be7cdf88 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -256,7 +256,7 @@ namespace osu.Game.Tests.Visual.Ranking public HotkeyRetryOverlay RetryOverlay; public TestResultsScreen(ScoreInfo score) - : base(score) + : base(score, true) { } @@ -326,7 +326,7 @@ namespace osu.Game.Tests.Visual.Ranking public HotkeyRetryOverlay RetryOverlay; public UnrankedSoloResultsScreen(ScoreInfo score) - : base(score) + : base(score, true) { Score.Beatmap.OnlineBeatmapID = 0; Score.Beatmap.Status = BeatmapSetOnlineStatus.Pending; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 4699784327..44c9361ff8 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -917,7 +917,7 @@ namespace osu.Game.Tests.Visual.SongSelect { get { - foreach (var item in ScrollableContent) + foreach (var item in Scroll.Children) { yield return item; diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index aa531ba106..35c6d62cb7 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -643,6 +643,55 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmap == filteredBeatmap); } + [Test] + public void TestChangingRulesetOnMultiRulesetBeatmap() + { + int changeCount = 0; + + AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, false)); + AddStep("bind beatmap changed", () => + { + Beatmap.ValueChanged += onChange; + changeCount = 0; + }); + + changeRuleset(0); + + createSongSelect(); + + AddStep("import multi-ruleset map", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); + }); + + int previousSetID = 0; + + AddUntilStep("wait for selection", () => !Beatmap.IsDefault); + + AddStep("record set ID", () => previousSetID = Beatmap.Value.BeatmapSetInfo.ID); + AddAssert("selection changed once", () => changeCount == 1); + + AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); + + changeRuleset(3); + + AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); + + AddUntilStep("selection changed", () => changeCount > 1); + + AddAssert("Selected beatmap still same set", () => Beatmap.Value.BeatmapSetInfo.ID == previousSetID); + AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.ID == 3); + + AddAssert("selection changed only fired twice", () => changeCount == 2); + + AddStep("unbind beatmap changed", () => Beatmap.ValueChanged -= onChange); + AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + + // ReSharper disable once AccessToModifiedClosure + void onChange(ValueChangedEvent valueChangedEvent) => changeCount++; + } + [Test] public void TestDifficultyIconSelectingForDifferentRuleset() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePaginatedContainerHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs similarity index 95% rename from osu.Game.Tests/Visual/UserInterface/TestScenePaginatedContainerHeader.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs index 2e9f919cfd..cd226662d7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePaginatedContainerHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs @@ -11,12 +11,12 @@ using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.UserInterface { - public class TestScenePaginatedContainerHeader : OsuTestScene + public class TestSceneProfileSubsectionHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - private PaginatedContainerHeader header; + private ProfileSubsectionHeader header; [Test] public void TestHiddenCounter() @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void createHeader(string text, CounterVisibilityState state, int initialValue = 0) { Clear(); - Add(header = new PaginatedContainerHeader(text, state) + Add(header = new ProfileSubsectionHeader(text, state) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index c692bcd5e4..83d7b4135a 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 5d55196dcf..bc6b994988 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index 999ce61ac8..71417d1cc6 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -243,7 +243,7 @@ namespace osu.Game.Tournament.IPC string stableInstallPath; using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) - stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); + stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); if (ipcFileExistsInDirectory(stableInstallPath)) return stableInstallPath; diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 8ebcbf4e15..bdfb1728f3 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -4,10 +4,10 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Drawing; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Tournament.Screens.Ladder.Components; -using SixLabors.Primitives; namespace osu.Game.Tournament.Models { diff --git a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs index efec4cffdd..ca46c3b050 100644 --- a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Drawing; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -16,7 +17,6 @@ using osu.Game.Tournament.Screens.Ladder; using osu.Game.Tournament.Screens.Ladder.Components; using osuTK; using osuTK.Graphics; -using SixLabors.Primitives; namespace osu.Game.Tournament.Screens.Editors { diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index f2065e7e88..1c805bb42e 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Drawing; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +14,6 @@ using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; using osuTK.Input; -using SixLabors.Primitives; namespace osu.Game.Tournament.Screens.Ladder.Components { diff --git a/osu.Game.Tournament/osu.Game.Tournament.csproj b/osu.Game.Tournament/osu.Game.Tournament.csproj index 9cce40c9d3..b049542bb0 100644 --- a/osu.Game.Tournament/osu.Game.Tournament.csproj +++ b/osu.Game.Tournament/osu.Game.Tournament.csproj @@ -9,6 +9,6 @@ - + \ No newline at end of file diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 8efaeb3795..3d90dd0189 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -1,8 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Utils; namespace osu.Game.Audio { @@ -10,7 +14,7 @@ namespace osu.Game.Audio /// Describes a gameplay hit sample. /// [Serializable] - public class HitSampleInfo : ISampleInfo + public class HitSampleInfo : ISampleInfo, IEquatable { public const string HIT_WHISTLE = @"hitwhistle"; public const string HIT_FINISH = @"hitfinish"; @@ -22,29 +26,38 @@ namespace osu.Game.Audio /// public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_CLAP, HIT_FINISH }; - /// - /// The bank to load the sample from. - /// - public string Bank; - /// /// The name of the sample to load. /// - public string Name; + public readonly string Name; + + /// + /// The bank to load the sample from. + /// + public readonly string? Bank; /// /// An optional suffix to provide priority lookup. Falls back to non-suffixed . /// - public string Suffix; + public readonly string? Suffix; /// /// The sample volume. /// - public int Volume { get; set; } + public int Volume { get; } + + public HitSampleInfo(string name, string? bank = null, string? suffix = null, int volume = 0) + { + Name = name; + Bank = bank; + Suffix = suffix; + Volume = volume; + } /// /// Retrieve all possible filenames that can be used as a source, returned in order of preference (highest first). /// + [JsonIgnore] public virtual IEnumerable LookupNames { get @@ -56,6 +69,23 @@ namespace osu.Game.Audio } } - public HitSampleInfo Clone() => (HitSampleInfo)MemberwiseClone(); + /// + /// Creates a new with overridden values. + /// + /// An optional new sample name. + /// An optional new sample bank. + /// An optional new lookup suffix. + /// An optional new volume. + /// The new . + public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); + + public bool Equals(HitSampleInfo? other) + => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix; + + public override bool Equals(object? obj) + => obj is HitSampleInfo other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Name, Bank, Suffix); } } diff --git a/osu.Game/Audio/SampleInfo.cs b/osu.Game/Audio/SampleInfo.cs index 240d70c418..5d8240204e 100644 --- a/osu.Game/Audio/SampleInfo.cs +++ b/osu.Game/Audio/SampleInfo.cs @@ -1,24 +1,41 @@ // 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; using System.Collections.Generic; +using System.Linq; namespace osu.Game.Audio { /// /// Describes a gameplay sample. /// - public class SampleInfo : ISampleInfo + public class SampleInfo : ISampleInfo, IEquatable { private readonly string[] sampleNames; public SampleInfo(params string[] sampleNames) { this.sampleNames = sampleNames; + Array.Sort(sampleNames); } public IEnumerable LookupNames => sampleNames; public int Volume { get; } = 100; + + public override int GetHashCode() + { + return HashCode.Combine( + StructuralComparisons.StructuralEqualityComparer.GetHashCode(sampleNames), + Volume); + } + + public bool Equals(SampleInfo other) + => other != null && sampleNames.SequenceEqual(other.sampleNames); + + public override bool Equals(object obj) + => obj is SampleInfo other && Equals(other); } } diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index f57ecfb9e3..fd0b496335 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -58,12 +58,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The name of the same. /// A populated . - public HitSampleInfo GetSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) => new HitSampleInfo - { - Bank = SampleBank, - Name = sampleName, - Volume = SampleVolume, - }; + public HitSampleInfo GetSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) => new HitSampleInfo(sampleName, SampleBank, volume: SampleVolume); /// /// Applies and to a if necessary, returning the modified . @@ -71,12 +66,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// The . This will not be modified. /// The modified . This does not share a reference with . public virtual HitSampleInfo ApplyTo(HitSampleInfo hitSampleInfo) - { - var newSampleInfo = hitSampleInfo.Clone(); - newSampleInfo.Bank = hitSampleInfo.Bank ?? SampleBank; - newSampleInfo.Volume = hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume; - return newSampleInfo; - } + => hitSampleInfo.With(newBank: hitSampleInfo.Bank ?? SampleBank, newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume); public override bool IsRedundant(ControlPoint existing) => existing is SampleControlPoint existingSample diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 7ddb0b4caa..df940e8c8e 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -192,7 +192,7 @@ namespace osu.Game.Beatmaps.Formats var effectPoint = beatmap.ControlPointInfo.EffectPointAt(time); // Apply the control point to a hit sample to uncover legacy properties (e.g. suffix) - HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo()); + HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo(string.Empty)); // Convert effect flags to the legacy format LegacyEffectFlags effectFlags = LegacyEffectFlags.None; diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index de4dc8cdc8..c9d139bdd0 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -182,11 +182,8 @@ namespace osu.Game.Beatmaps.Formats { var baseInfo = base.ApplyTo(hitSampleInfo); - if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy - && legacy.CustomSampleBank == 0) - { - legacy.CustomSampleBank = CustomSampleBank; - } + if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) + return legacy.With(newCustomSampleBank: CustomSampleBank); return baseInfo; } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index a4b99bb6e6..a07e446d2e 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -184,7 +184,7 @@ namespace osu.Game.Configuration return new TrackedSettings { new TrackedSetting(OsuSetting.MouseDisableButtons, v => new SettingDescription(!v, "gameplay mouse buttons", v ? "disabled" : "enabled", LookupKeyBindings(GlobalAction.ToggleGameplayMouseButtons))), - new TrackedSetting(OsuSetting.HUDVisibilityMode, m => new SettingDescription(m, "HUD Visibility", m.GetDescription(), $"cycle: shift-tab quick view: {LookupKeyBindings(GlobalAction.HoldForHUD)}")), + new TrackedSetting(OsuSetting.HUDVisibilityMode, m => new SettingDescription(m, "HUD Visibility", m.GetDescription(), $"cycle: {LookupKeyBindings(GlobalAction.ToggleInGameInterface)} quick view: {LookupKeyBindings(GlobalAction.HoldForHUD)}")), new TrackedSetting(OsuSetting.Scaling, m => new SettingDescription(m, "scaling", m.GetDescription())), new TrackedSetting(OsuSetting.Skin, m => { @@ -196,7 +196,7 @@ namespace osu.Game.Configuration public Func LookupSkinName { private get; set; } - public Func LookupKeyBindings { private get; set; } + public Func LookupKeyBindings { get; set; } } public enum OsuSetting diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 5b0fa44444..0e9382279a 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -60,6 +60,7 @@ namespace osu.Game.Graphics.Backgrounds /// /// Whether we want to expire triangles as they exit our draw area completely. /// + [Obsolete("Unused.")] // Can be removed 20210518 protected virtual bool ExpireOffScreenTriangles => true; /// @@ -86,12 +87,9 @@ namespace osu.Game.Graphics.Backgrounds /// public float Velocity = 1; - private readonly Random stableRandom; - - private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle()); - private readonly SortedList parts = new SortedList(Comparer.Default); + private Random stableRandom; private IShader shader; private readonly Texture texture; @@ -172,7 +170,20 @@ namespace osu.Game.Graphics.Backgrounds } } - protected int AimCount; + /// + /// Clears and re-initialises triangles according to a given seed. + /// + /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time. + public void Reset(int? seed = null) + { + if (seed != null) + stableRandom = new Random(seed.Value); + + parts.Clear(); + addTriangles(true); + } + + protected int AimCount { get; private set; } private void addTriangles(bool randomY) { @@ -226,6 +237,8 @@ namespace osu.Game.Graphics.Backgrounds } } + private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle()); + protected override DrawNode CreateDrawNode() => new TrianglesDrawNode(this); private class TrianglesDrawNode : DrawNode diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index b9122d254d..aaad72f65c 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -12,7 +12,19 @@ using osuTK.Input; namespace osu.Game.Graphics.Containers { - public class OsuScrollContainer : ScrollContainer + public class OsuScrollContainer : OsuScrollContainer + { + public OsuScrollContainer() + { + } + + public OsuScrollContainer(Direction direction) + : base(direction) + { + } + } + + public class OsuScrollContainer : ScrollContainer where T : Drawable { public const float SCROLL_BAR_HEIGHT = 10; public const float SCROLL_BAR_PADDING = 3; diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index f32f8e0c67..81968de304 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,6 +15,7 @@ namespace osu.Game.Graphics.Containers /// /// A container that can scroll to each section inside it. /// + [Cached] public class SectionsContainer : Container where T : Drawable { diff --git a/osu.Game/Graphics/ParticleExplosion.cs b/osu.Game/Graphics/ParticleExplosion.cs new file mode 100644 index 0000000000..e0d2b50c55 --- /dev/null +++ b/osu.Game/Graphics/ParticleExplosion.cs @@ -0,0 +1,144 @@ +// 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 osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Game.Graphics +{ + /// + /// An explosion of textured particles based on how osu-stable randomises the explosion pattern. + /// + public class ParticleExplosion : Sprite + { + private readonly int particleCount; + private readonly double duration; + private double startTime; + + private readonly List parts = new List(); + + public ParticleExplosion(Texture texture, int particleCount, double duration) + { + Texture = texture; + this.particleCount = particleCount; + this.duration = duration; + Blending = BlendingParameters.Additive; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Restart(); + } + + /// + /// Restart the animation from the current point in time. + /// Supports transform time offset chaining. + /// + public void Restart() + { + startTime = TransformStartTime; + this.FadeOutFromOne(duration); + + parts.Clear(); + for (int i = 0; i < particleCount; i++) + parts.Add(new ParticlePart(duration)); + } + + protected override void Update() + { + base.Update(); + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new ParticleExplosionDrawNode(this); + + private class ParticleExplosionDrawNode : SpriteDrawNode + { + private readonly List parts = new List(); + + private ParticleExplosion source => (ParticleExplosion)Source; + + private double startTime; + private double currentTime; + private Vector2 sourceSize; + + public ParticleExplosionDrawNode(Sprite source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + parts.Clear(); + parts.AddRange(source.parts); + + sourceSize = source.Size; + startTime = source.startTime; + currentTime = source.Time.Current; + } + + protected override void Blit(Action vertexAction) + { + var time = currentTime - startTime; + + foreach (var p in parts) + { + Vector2 pos = p.PositionAtTime(time); + float alpha = p.AlphaAtTime(time); + + var rect = new RectangleF( + pos.X * sourceSize.X - Texture.DisplayWidth / 2, + pos.Y * sourceSize.Y - Texture.DisplayHeight / 2, + Texture.DisplayWidth, + Texture.DisplayHeight); + + // convert to screen space. + var quad = new Quad( + Vector2Extensions.Transform(rect.TopLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(rect.TopRight, DrawInfo.Matrix), + Vector2Extensions.Transform(rect.BottomLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(rect.BottomRight, DrawInfo.Matrix) + ); + + DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, + new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + null, TextureCoords); + } + } + } + + private readonly struct ParticlePart + { + private readonly double duration; + private readonly float direction; + private readonly float distance; + + public ParticlePart(double availableDuration) + { + distance = RNG.NextSingle(0.5f); + duration = RNG.NextDouble(availableDuration / 3, availableDuration); + direction = RNG.NextSingle(0, MathF.PI * 2); + } + + public float AlphaAtTime(double time) => 1 - progressAtTime(time); + + public Vector2 PositionAtTime(double time) + { + var travelledDistance = distance * progressAtTime(time); + return new Vector2(0.5f) + travelledDistance * new Vector2(MathF.Sin(direction), MathF.Cos(direction)); + } + + private float progressAtTime(double time) => (float)Math.Clamp(time / duration, 0, 1); + } + } +} diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index d1f6fd445e..53ee711626 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -116,13 +116,13 @@ namespace osu.Game.Graphics switch (screenshotFormat.Value) { case ScreenshotFormat.Png: - image.SaveAsPng(stream); + await image.SaveAsPngAsync(stream); break; case ScreenshotFormat.Jpg: const int jpeg_quality = 92; - image.SaveAsJpeg(stream, new JpegEncoder { Quality = jpeg_quality }); + await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }); break; default: diff --git a/osu.Game/Graphics/UserInterface/LineGraph.cs b/osu.Game/Graphics/UserInterface/LineGraph.cs index 42b523fc5c..70db26c817 100644 --- a/osu.Game/Graphics/UserInterface/LineGraph.cs +++ b/osu.Game/Graphics/UserInterface/LineGraph.cs @@ -119,7 +119,11 @@ namespace osu.Game.Graphics.UserInterface protected float GetYPosition(float value) { - if (ActualMaxValue == ActualMinValue) return 0; + if (ActualMaxValue == ActualMinValue) + // show line at top if the only value on the graph is positive, + // and at bottom if the only value on the graph is zero or negative. + // just kind of makes most sense intuitively. + return value > 1 ? 0 : 1; return (ActualMaxValue - value) / (ActualMaxValue - ActualMinValue); } diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index b13d6485ac..894a21fcf3 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -147,7 +147,7 @@ namespace osu.Game.Graphics.UserInterface public override void DisplayAt(float scale) { - scale = Math.Clamp(scale, min_star_scale, 1); + scale = (float)Interpolation.Lerp(min_star_scale, 1, Math.Clamp(scale, 0, 1)); this.FadeTo(scale, fading_duration); Icon.ScaleTo(scale, scaling_duration, scaling_easing); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index e5d3a89a88..1270df5374 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -68,7 +68,9 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Tilde }, GlobalAction.QuickExit), new KeyBinding(new[] { InputKey.Control, InputKey.Plus }, GlobalAction.IncreaseScrollSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Minus }, GlobalAction.DecreaseScrollSpeed), + new KeyBinding(new[] { InputKey.Shift, InputKey.Tab }, GlobalAction.ToggleInGameInterface), new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), + new KeyBinding(InputKey.Space, GlobalAction.TogglePauseReplay), new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD), }; @@ -147,7 +149,7 @@ namespace osu.Game.Input.Bindings [Description("Select")] Select, - [Description("Quick exit (Hold)")] + [Description("Quick exit (hold)")] QuickExit, // Game-wide beatmap music controller keybindings @@ -163,10 +165,10 @@ namespace osu.Game.Input.Bindings [Description("Toggle now playing overlay")] ToggleNowPlaying, - [Description("Previous Selection")] + [Description("Previous selection")] SelectPrevious, - [Description("Next Selection")] + [Description("Next selection")] SelectNext, [Description("Home")] @@ -175,26 +177,32 @@ namespace osu.Game.Input.Bindings [Description("Toggle notifications")] ToggleNotifications, - [Description("Pause")] + [Description("Pause gameplay")] PauseGameplay, // Editor - [Description("Setup Mode")] + [Description("Setup mode")] EditorSetupMode, - [Description("Compose Mode")] + [Description("Compose mode")] EditorComposeMode, - [Description("Design Mode")] + [Description("Design mode")] EditorDesignMode, - [Description("Timing Mode")] + [Description("Timing mode")] EditorTimingMode, [Description("Hold for HUD")] HoldForHUD, - [Description("Random Skin")] + [Description("Random skin")] RandomSkin, + + [Description("Pause / resume replay")] + TogglePauseReplay, + + [Description("Toggle in-game interface")] + ToggleInGameInterface, } } diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs index 3dadae6317..75d9c8debb 100644 --- a/osu.Game/Input/ConfineMouseTracker.cs +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -18,6 +18,8 @@ namespace osu.Game.Input public class ConfineMouseTracker : Component { private Bindable frameworkConfineMode; + private Bindable frameworkWindowMode; + private Bindable osuConfineMode; private IBindable localUserPlaying; @@ -25,6 +27,9 @@ namespace osu.Game.Input private void load(OsuGame game, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) { frameworkConfineMode = frameworkConfigManager.GetBindable(FrameworkSetting.ConfineMouseMode); + frameworkWindowMode = frameworkConfigManager.GetBindable(FrameworkSetting.WindowMode); + frameworkWindowMode.BindValueChanged(_ => updateConfineMode()); + osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); @@ -38,16 +43,18 @@ namespace osu.Game.Input if (frameworkConfineMode.Disabled) return; + if (frameworkWindowMode.Value == WindowMode.Fullscreen) + { + frameworkConfineMode.Value = ConfineMouseMode.Fullscreen; + return; + } + switch (osuConfineMode.Value) { case OsuConfineMouseMode.Never: frameworkConfineMode.Value = ConfineMouseMode.Never; break; - case OsuConfineMouseMode.Fullscreen: - frameworkConfineMode.Value = ConfineMouseMode.Fullscreen; - break; - case OsuConfineMouseMode.DuringGameplay: frameworkConfineMode.Value = localUserPlaying.Value ? ConfineMouseMode.Always : ConfineMouseMode.Never; break; diff --git a/osu.Game/Input/OsuConfineMouseMode.cs b/osu.Game/Input/OsuConfineMouseMode.cs index 32b456395c..a4a1c9eb46 100644 --- a/osu.Game/Input/OsuConfineMouseMode.cs +++ b/osu.Game/Input/OsuConfineMouseMode.cs @@ -17,11 +17,6 @@ namespace osu.Game.Input /// Never, - /// - /// The mouse cursor will be locked to the window bounds while in fullscreen mode. - /// - Fullscreen, - /// /// The mouse cursor will be locked to the window bounds during gameplay, /// but may otherwise move freely. diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index ae65ac09b2..7343870dbc 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -75,6 +75,7 @@ namespace osu.Game.Online.API.Requests.Responses StarDifficulty = starDifficulty, OnlineBeatmapID = OnlineBeatmapID, Version = version, + // this is actually an incorrect mapping (Length is calculated as drain length in lazer's import process, see BeatmapManager.calculateLength). Length = TimeSpan.FromSeconds(length).TotalMilliseconds, Status = Status, BeatmapSet = set, diff --git a/osu.Game/Online/Multiplayer/PlaylistExtensions.cs b/osu.Game/Online/Multiplayer/PlaylistExtensions.cs new file mode 100644 index 0000000000..fe3d96e295 --- /dev/null +++ b/osu.Game/Online/Multiplayer/PlaylistExtensions.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 System.Linq; +using Humanizer; +using Humanizer.Localisation; +using osu.Framework.Bindables; + +namespace osu.Game.Online.Multiplayer +{ + public static class PlaylistExtensions + { + public static string GetTotalDuration(this BindableList playlist) => + playlist.Select(p => p.Beatmap.Value.Length).Sum().Milliseconds().Humanize(minUnit: TimeUnit.Second, maxUnit: TimeUnit.Hour, precision: 2); + } +} diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs index 9ba81720d8..08b524087a 100644 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs @@ -36,6 +36,8 @@ namespace osu.Game.Online.Spectator private readonly List watchingUsers = new List(); + private readonly object userLock = new object(); + public IBindableList PlayingUsers => playingUsers; private readonly BindableList playingUsers = new BindableList(); @@ -144,12 +146,19 @@ namespace osu.Game.Online.Spectator await connection.StartAsync(); Logger.Log("Spectator client connected!", LoggingTarget.Network); + // get all the users that were previously being watched + int[] users; + + lock (userLock) + { + users = watchingUsers.ToArray(); + watchingUsers.Clear(); + } + // success isConnected = true; // resubscribe to watched users - var users = watchingUsers.ToArray(); - watchingUsers.Clear(); foreach (var userId in users) WatchUser(userId); @@ -238,21 +247,29 @@ namespace osu.Game.Online.Spectator public virtual void WatchUser(int userId) { - if (watchingUsers.Contains(userId)) - return; + lock (userLock) + { + if (watchingUsers.Contains(userId)) + return; - watchingUsers.Add(userId); + watchingUsers.Add(userId); - if (!isConnected) return; + if (!isConnected) + return; + } connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); } public void StopWatchingUser(int userId) { - watchingUsers.Remove(userId); + lock (userLock) + { + watchingUsers.Remove(userId); - if (!isConnected) return; + if (!isConnected) + return; + } connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index cb0e2cfa8e..bb638bcf3a 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -420,7 +420,7 @@ namespace osu.Game break; case ScorePresentType.Results: - screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo)); + screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo, false)); break; } }, validScreens: new[] { typeof(PlaySongSelect) }); diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index e6edfb1e3e..f06e02e5e1 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Overlays.OSD; @@ -37,11 +38,11 @@ namespace osu.Game.Overlays.Music bool wasPlaying = musicController.IsPlaying; if (musicController.TogglePause()) - onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track")); + onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track", action)); return true; case GlobalAction.MusicNext: - musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track"))); + musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track", action))); return true; @@ -51,11 +52,11 @@ namespace osu.Game.Overlays.Music switch (res) { case PreviousTrackResult.Restart: - onScreenDisplay?.Display(new MusicActionToast("Restart track")); + onScreenDisplay?.Display(new MusicActionToast("Restart track", action)); break; case PreviousTrackResult.Previous: - onScreenDisplay?.Display(new MusicActionToast("Previous track")); + onScreenDisplay?.Display(new MusicActionToast("Previous track", action)); break; } }); @@ -72,9 +73,18 @@ namespace osu.Game.Overlays.Music private class MusicActionToast : Toast { - public MusicActionToast(string action) - : base("Music Playback", action, string.Empty) + private readonly GlobalAction action; + + public MusicActionToast(string value, GlobalAction action) + : base("Music Playback", value, string.Empty) { + this.action = action; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + ShortcutText.Text = config.LookupKeyBindings(action).ToUpperInvariant(); } } } diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index b5714fbcae..d51d964fc4 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -6,13 +6,13 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Notifications; -using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Containers; using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Threading; +using osu.Game.Graphics; namespace osu.Game.Overlays { @@ -44,8 +44,7 @@ namespace osu.Game.Overlays new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.6f + Colour = OsuColour.Gray(0.05f), }, new OsuScrollContainer { diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 1497ca8fa8..4a6316df3f 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -16,10 +16,13 @@ namespace osu.Game.Overlays.OSD private const int toast_minimum_width = 240; private readonly Container content; + protected override Container Content => content; protected readonly OsuSpriteText ValueText; + protected readonly OsuSpriteText ShortcutText; + protected Toast(string description, string value, string shortcut) { Anchor = Anchor.Centre; @@ -68,7 +71,7 @@ namespace osu.Game.Overlays.OSD Origin = Anchor.Centre, Text = value }, - new OsuSpriteText + ShortcutText = new OsuSpriteText { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 4b7de8de90..780d7ea986 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Overlays.Profile.Sections.Beatmaps { - public class PaginatedBeatmapContainer : PaginatedContainer + public class PaginatedBeatmapContainer : PaginatedProfileSubsection { private const float panel_padding = 10f; private readonly BeatmapSetType type; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs new file mode 100644 index 0000000000..b82773155d --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs @@ -0,0 +1,84 @@ +// 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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Users; +using static osu.Game.Users.User; + +namespace osu.Game.Overlays.Profile.Sections.Historical +{ + public abstract class ChartProfileSubsection : ProfileSubsection + { + private ProfileLineChart chart; + + protected ChartProfileSubsection(Bindable user, string headerText) + : base(user, headerText) + { + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Top = 10, + Left = 20, + Right = 40 + }, + Child = chart = new ProfileLineChart() + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(onUserChanged, true); + } + + private void onUserChanged(ValueChangedEvent e) + { + var values = GetValues(e.NewValue); + + if (values == null || values.Length <= 1) + { + Hide(); + return; + } + + chart.Values = fillZeroValues(values); + Show(); + } + + /// + /// Add entries for any missing months (filled with zero values). + /// + private UserHistoryCount[] fillZeroValues(UserHistoryCount[] historyEntries) + { + var filledHistoryEntries = new List(); + + foreach (var entry in historyEntries) + { + var lastFilled = filledHistoryEntries.LastOrDefault(); + + while (lastFilled?.Date.AddMonths(1) < entry.Date) + { + filledHistoryEntries.Add(lastFilled = new UserHistoryCount + { + Count = 0, + Date = lastFilled.Date.AddMonths(1) + }); + } + + filledHistoryEntries.Add(entry); + } + + return filledHistoryEntries.ToArray(); + } + + protected abstract UserHistoryCount[] GetValues(User user); + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index 556f3139dd..e5bb1f8008 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -13,7 +13,7 @@ using osu.Game.Users; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class PaginatedMostPlayedBeatmapContainer : PaginatedContainer + public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { public PaginatedMostPlayedBeatmapContainer(Bindable user) : base(user, "Most Played Beatmaps", "No records. :(", CounterVisibilityState.AlwaysVisible) diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs new file mode 100644 index 0000000000..2f15886c3a --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.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.Bindables; +using osu.Game.Users; +using static osu.Game.Users.User; + +namespace osu.Game.Overlays.Profile.Sections.Historical +{ + public class PlayHistorySubsection : ChartProfileSubsection + { + public PlayHistorySubsection(Bindable user) + : base(user, "Play History") + { + } + + protected override UserHistoryCount[] GetValues(User user) => user?.MonthlyPlaycounts; + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs new file mode 100644 index 0000000000..f02aa36b6c --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs @@ -0,0 +1,259 @@ +// 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.Containers; +using osu.Framework.Graphics; +using JetBrains.Annotations; +using System; +using System.Linq; +using osu.Game.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Framework.Allocation; +using osu.Game.Graphics; +using osu.Framework.Graphics.Shapes; +using osuTK; +using static osu.Game.Users.User; + +namespace osu.Game.Overlays.Profile.Sections.Historical +{ + public class ProfileLineChart : CompositeDrawable + { + private UserHistoryCount[] values; + + [NotNull] + public UserHistoryCount[] Values + { + get => values; + set + { + if (value.Length == 0) + throw new ArgumentException("At least one value expected!", nameof(value)); + + graph.Values = values = value; + + createRowTicks(); + createColumnTicks(); + } + } + + private readonly UserHistoryGraph graph; + private readonly Container rowTicksContainer; + private readonly Container columnTicksContainer; + private readonly Container rowLinesContainer; + private readonly Container columnLinesContainer; + + public ProfileLineChart() + { + RelativeSizeAxes = Axes.X; + Height = 250; + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + rowTicksContainer = new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + rowLinesContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + columnLinesContainer = new Container + { + RelativeSizeAxes = Axes.Both + } + } + }, + graph = new UserHistoryGraph + { + RelativeSizeAxes = Axes.Both + } + } + } + }, + new[] + { + Empty(), + columnTicksContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Top = 10 } + } + } + } + }; + } + + private void createRowTicks() + { + rowTicksContainer.Clear(); + rowLinesContainer.Clear(); + + var min = values.Select(v => v.Count).Min(); + var max = values.Select(v => v.Count).Max(); + + var tickInterval = getTickInterval(max - min, 6); + + for (long currentTick = 0; currentTick <= max; currentTick += tickInterval) + { + if (currentTick < min) + continue; + + float y; + + // special-case the min == max case to match LineGraph. + // lerp isn't really well-defined over a zero interval anyway. + if (min == max) + y = currentTick > 1 ? 1 : 0; + else + y = Interpolation.ValueAt(currentTick, 0, 1f, min, max); + + // y axis is inverted in graph-like coordinates. + addRowTick(-y, currentTick); + } + } + + private void createColumnTicks() + { + columnTicksContainer.Clear(); + columnLinesContainer.Clear(); + + var totalMonths = values.Length; + + int monthsPerTick = 1; + + if (totalMonths > 80) + monthsPerTick = 12; + else if (totalMonths >= 45) + monthsPerTick = 3; + else if (totalMonths > 20) + monthsPerTick = 2; + + for (int i = 0; i < totalMonths; i += monthsPerTick) + { + var x = (float)i / (totalMonths - 1); + addColumnTick(x, values[i].Date); + } + } + + private void addRowTick(float y, double value) + { + rowTicksContainer.Add(new TickText + { + Anchor = Anchor.BottomRight, + Origin = Anchor.CentreRight, + RelativePositionAxes = Axes.Y, + Margin = new MarginPadding { Right = 3 }, + Text = value.ToString("N0"), + Font = OsuFont.GetFont(size: 12), + Y = y + }); + + rowLinesContainer.Add(new TickLine + { + Anchor = Anchor.BottomRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + RelativePositionAxes = Axes.Y, + Height = 0.1f, + EdgeSmoothness = Vector2.One, + Y = y + }); + } + + private void addColumnTick(float x, DateTime value) + { + columnTicksContainer.Add(new TickText + { + Origin = Anchor.CentreLeft, + RelativePositionAxes = Axes.X, + Text = value.ToString("MMM yyyy"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Rotation = 45, + X = x + }); + + columnLinesContainer.Add(new TickLine + { + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + Width = 0.1f, + EdgeSmoothness = Vector2.One, + X = x + }); + } + + private long getTickInterval(long range, int maxTicksCount) + { + // this interval is what would be achieved if the interval was divided perfectly evenly into maxTicksCount ticks. + // can contain ugly fractional parts. + var exactTickInterval = (float)range / (maxTicksCount - 1); + + // the ideal ticks start with a 1, 2 or 5, and are multipliers of powers of 10. + // first off, use log10 to calculate the number of digits in the "exact" interval. + var numberOfDigits = Math.Floor(Math.Log10(exactTickInterval)); + var tickBase = Math.Pow(10, numberOfDigits); + + // then see how the exact tick relates to the power of 10. + var exactTickMultiplier = exactTickInterval / tickBase; + + double tickMultiplier; + + // round up the fraction to start with a 1, 2 or 5. closest match wins. + if (exactTickMultiplier < 1.5) + tickMultiplier = 1.0; + else if (exactTickMultiplier < 3) + tickMultiplier = 2.0; + else if (exactTickMultiplier < 7) + tickMultiplier = 5.0; + else + tickMultiplier = 10.0; + + return Math.Max((long)(tickMultiplier * tickBase), 1); + } + + private class TickText : OsuSpriteText + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Foreground1; + } + } + + private class TickLine : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background6; + } + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs new file mode 100644 index 0000000000..e594e8d020 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.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.Bindables; +using osu.Game.Users; +using static osu.Game.Users.User; + +namespace osu.Game.Overlays.Profile.Sections.Historical +{ + public class ReplaysSubsection : ChartProfileSubsection + { + public ReplaysSubsection(Bindable user) + : base(user, "Replays Watched History") + { + } + + protected override UserHistoryCount[] GetValues(User user) => user?.ReplaysWatchedCounts; + } +} diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index bfc47bd88c..6e2b9873cf 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -18,8 +18,10 @@ namespace osu.Game.Overlays.Profile.Sections { Children = new Drawable[] { + new PlayHistorySubsection(User), new PaginatedMostPlayedBeatmapContainer(User), new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)", CounterVisibilityState.VisibleWhenZero), + new ReplaysSubsection(User) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs index 1b8bd23eb4..008d89d881 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs @@ -11,7 +11,7 @@ using System.Collections.Generic; namespace osu.Game.Overlays.Profile.Sections.Kudosu { - public class PaginatedKudosuHistoryContainer : PaginatedContainer + public class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection { public PaginatedKudosuHistoryContainer(Bindable user) : base(user, missingText: "This user hasn't received any kudosu!") diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs similarity index 72% rename from osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs rename to osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index c1107ce907..51e5622f68 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -6,62 +6,51 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Online.API; -using osu.Game.Rulesets; using osu.Game.Users; using System.Collections.Generic; using System.Linq; using System.Threading; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; namespace osu.Game.Overlays.Profile.Sections { - public abstract class PaginatedContainer : FillFlowContainer + public abstract class PaginatedProfileSubsection : ProfileSubsection { [Resolved] private IAPIProvider api { get; set; } + [Resolved] + protected RulesetStore Rulesets { get; private set; } + protected int VisiblePages; protected int ItemsPerPage; - protected readonly Bindable User = new Bindable(); - protected FillFlowContainer ItemsContainer; - protected RulesetStore Rulesets; + protected FillFlowContainer ItemsContainer { get; private set; } private APIRequest> retrievalRequest; private CancellationTokenSource loadCancellation; - private readonly string missingText; private ShowMoreButton moreButton; private OsuSpriteText missing; - private PaginatedContainerHeader header; + private readonly string missingText; - private readonly string headerText; - private readonly CounterVisibilityState counterVisibilityState; - - protected PaginatedContainer(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + : base(user, headerText, counterVisibilityState) { - this.headerText = headerText; this.missingText = missingText; - this.counterVisibilityState = counterVisibilityState; - User.BindTo(user); } - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + protected override Drawable CreateContent() => new FillFlowContainer { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Direction = FillDirection.Vertical; - + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, Children = new Drawable[] { - header = new PaginatedContainerHeader(headerText, counterVisibilityState) - { - Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1 - }, ItemsContainer = new FillFlowContainer { AutoSizeAxes = Axes.Y, @@ -81,13 +70,14 @@ namespace osu.Game.Overlays.Profile.Sections Font = OsuFont.GetFont(size: 15), Text = missingText, Alpha = 0, - }, - }; + } + } + }; - Rulesets = rulesets; - - User.ValueChanged += onUserChanged; - User.TriggerChange(); + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(onUserChanged, true); } private void onUserChanged(ValueChangedEvent e) @@ -124,7 +114,7 @@ namespace osu.Game.Overlays.Profile.Sections moreButton.Hide(); moreButton.IsLoading = false; - if (!string.IsNullOrEmpty(missing.Text)) + if (!string.IsNullOrEmpty(missingText)) missing.Show(); return; @@ -142,8 +132,6 @@ namespace osu.Game.Overlays.Profile.Sections protected virtual int GetCount(User user) => 0; - protected void SetCount(int value) => header.Current.Value = value; - protected virtual void OnItemsReceived(List items) { } @@ -154,8 +142,9 @@ namespace osu.Game.Overlays.Profile.Sections protected override void Dispose(bool isDisposing) { - base.Dispose(isDisposing); retrievalRequest?.Cancel(); + loadCancellation?.Cancel(); + base.Dispose(isDisposing); } } } diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs new file mode 100644 index 0000000000..3e331f85e9 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs @@ -0,0 +1,51 @@ +// 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.Game.Users; +using JetBrains.Annotations; + +namespace osu.Game.Overlays.Profile.Sections +{ + public abstract class ProfileSubsection : FillFlowContainer + { + protected readonly Bindable User = new Bindable(); + + private readonly string headerText; + private readonly CounterVisibilityState counterVisibilityState; + + private ProfileSubsectionHeader header; + + protected ProfileSubsection(Bindable user, string headerText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + { + this.headerText = headerText; + this.counterVisibilityState = counterVisibilityState; + User.BindTo(user); + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + + Children = new[] + { + header = new ProfileSubsectionHeader(headerText, counterVisibilityState) + { + Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1 + }, + CreateContent() + }; + } + + [NotNull] + protected abstract Drawable CreateContent(); + + protected void SetCount(int value) => header.Current.Value = value; + } +} diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainerHeader.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs similarity index 95% rename from osu.Game/Overlays/Profile/Sections/PaginatedContainerHeader.cs rename to osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs index 8c617e5fbd..5858cebe89 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedContainerHeader.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics; namespace osu.Game.Overlays.Profile.Sections { - public class PaginatedContainerHeader : CompositeDrawable, IHasCurrentValue + public class ProfileSubsectionHeader : CompositeDrawable, IHasCurrentValue { private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -29,7 +29,7 @@ namespace osu.Game.Overlays.Profile.Sections private CounterPill counterPill; - public PaginatedContainerHeader(string text, CounterVisibilityState counterState) + public ProfileSubsectionHeader(string text, CounterVisibilityState counterState) { this.text = text; this.counterState = counterState; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 1ce3079d52..53f6d375ca 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -14,7 +14,7 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class PaginatedScoreContainer : PaginatedContainer + public class PaginatedScoreContainer : PaginatedProfileSubsection { private readonly ScoreType type; diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index 08f39c6272..d7101a8147 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -13,7 +13,7 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class PaginatedRecentActivityContainer : PaginatedContainer + public class PaginatedRecentActivityContainer : PaginatedProfileSubsection { public PaginatedRecentActivityContainer(Bindable user) : base(user, missingText: "This user hasn't done anything notable recently!") diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index 69538358f1..7072d8e63d 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -27,7 +27,6 @@ namespace osu.Game.Overlays.Settings.Sections new AudioDevicesSettings(), new VolumeSettings(), new OffsetSettings(), - new MainMenuSettings() }; } } diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index e5cebd28e2..acb94a6a01 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -26,7 +26,6 @@ namespace osu.Game.Overlays.Settings.Sections Children = new Drawable[] { new GeneralSettings(), - new SongSelectSettings(), new ModsSettings(), }; } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 14b8dbfac0..62dc1dc806 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -208,7 +208,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private IReadOnlyList getResolutions() { var resolutions = new List { new Size(9999, 9999) }; - var currentDisplay = game.Window?.CurrentDisplay.Value; + var currentDisplay = game.Window?.CurrentDisplayBindable.Value; if (currentDisplay != null) { diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index c1b4b0bbcb..4ade48031f 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -23,7 +23,6 @@ namespace osu.Game.Overlays.Settings.Sections new RendererSettings(), new LayoutSettings(), new DetailSettings(), - new UserInterfaceSettings(), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index f0d51a0d37..b54ad9a641 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private readonly BindableBool rawInputToggle = new BindableBool(); private Bindable sensitivityBindable = new BindableDouble(); - private Bindable ignoredInputHandler; + private Bindable ignoredInputHandlers; [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig, FrameworkConfigManager config) @@ -75,20 +75,20 @@ namespace osu.Game.Overlays.Settings.Sections.Input { // this is temporary until we support per-handler settings. const string raw_mouse_handler = @"OsuTKRawMouseHandler"; - const string standard_mouse_handler = @"OsuTKMouseHandler"; + const string standard_mouse_handlers = @"OsuTKMouseHandler MouseHandler"; - ignoredInputHandler.Value = enabled.NewValue ? standard_mouse_handler : raw_mouse_handler; + ignoredInputHandlers.Value = enabled.NewValue ? standard_mouse_handlers : raw_mouse_handler; }; - ignoredInputHandler = config.GetBindable(FrameworkSetting.IgnoredInputHandlers); - ignoredInputHandler.ValueChanged += handler => + ignoredInputHandlers = config.GetBindable(FrameworkSetting.IgnoredInputHandlers); + ignoredInputHandlers.ValueChanged += handler => { bool raw = !handler.NewValue.Contains("Raw"); rawInputToggle.Value = raw; sensitivityBindable.Disabled = !raw; }; - ignoredInputHandler.TriggerChange(); + ignoredInputHandlers.TriggerChange(); } } diff --git a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs new file mode 100644 index 0000000000..101d8f43f7 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/SizeSlider.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.Graphics.UserInterface; + +namespace osu.Game.Overlays.Settings.Sections +{ + /// + /// A slider intended to show a "size" multiplier number, where 1x is 1.0. + /// + internal class SizeSlider : OsuSliderBar + { + public override string TooltipText => Current.Value.ToString(@"0.##x"); + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 3e7068f1ff..5898482e4a 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -54,12 +54,6 @@ namespace osu.Game.Overlays.Settings.Sections skinDropdown = new SkinSettingsDropdown(), new ExportSkinButton(), new SettingsSlider - { - LabelText = "Menu cursor size", - Current = config.GetBindable(OsuSetting.MenuCursorSize), - KeyboardStep = 0.01f - }, - new SettingsSlider { LabelText = "Gameplay cursor size", Current = config.GetBindable(OsuSetting.GameplayCursorSize), @@ -136,11 +130,6 @@ namespace osu.Game.Overlays.Settings.Sections Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => i.ID != item.ID).ToArray()); } - private class SizeSlider : OsuSliderBar - { - public override string TooltipText => Current.Value.ToString(@"0.##x"); - } - private class SkinSettingsDropdown : SettingsDropdown { protected override OsuDropdown CreateDropdown() => new SkinDropdownControl(); diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs similarity index 75% rename from osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs rename to osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index 38c30ddd64..19adfc5dd9 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -6,11 +6,11 @@ using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; -namespace osu.Game.Overlays.Settings.Sections.Graphics +namespace osu.Game.Overlays.Settings.Sections.UserInterface { - public class UserInterfaceSettings : SettingsSubsection + public class GeneralSettings : SettingsSubsection { - protected override string Header => "User Interface"; + protected override string Header => "General"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -22,6 +22,12 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics LabelText = "Rotate cursor when dragging", Current = config.GetBindable(OsuSetting.CursorRotation) }, + new SettingsSlider + { + LabelText = "Menu cursor size", + Current = config.GetBindable(OsuSetting.MenuCursorSize), + KeyboardStep = 0.01f + }, new SettingsCheckbox { LabelText = "Parallax", diff --git a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs similarity index 97% rename from osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs rename to osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 7682967d10..598b666642 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -7,7 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; -namespace osu.Game.Overlays.Settings.Sections.Audio +namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class MainMenuSettings : SettingsSubsection { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs similarity index 97% rename from osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs rename to osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index b26876556e..c73a783d37 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; -namespace osu.Game.Overlays.Settings.Sections.Gameplay +namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class SongSelectSettings : SettingsSubsection { diff --git a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs new file mode 100644 index 0000000000..718fea5f2b --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs @@ -0,0 +1,29 @@ +// 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.Sprites; +using osu.Game.Overlays.Settings.Sections.UserInterface; + +namespace osu.Game.Overlays.Settings.Sections +{ + public class UserInterfaceSection : SettingsSection + { + public override string Header => "User Interface"; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.LayerGroup + }; + + public UserInterfaceSection() + { + Children = new Drawable[] + { + new GeneralSettings(), + new MainMenuSettings(), + new SongSelectSettings() + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index 97e4ba9da7..4143605c28 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -1,16 +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 osuTK; -using osuTK.Graphics; +using System.Collections.Generic; +using System.Linq; 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.Graphics.Sprites; -using System.Collections.Generic; -using System.Linq; +using osuTK.Graphics; namespace osu.Game.Overlays.Settings { @@ -26,7 +25,7 @@ namespace osu.Game.Overlays.Settings public virtual IEnumerable FilterTerms => new[] { Header }; private const int header_size = 26; - private const int header_margin = 25; + private const int margin = 20; private const int border_size = 2; public bool MatchingFilter @@ -38,7 +37,7 @@ namespace osu.Game.Overlays.Settings protected SettingsSection() { - Margin = new MarginPadding { Top = 20 }; + Margin = new MarginPadding { Top = margin }; AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; @@ -46,10 +45,9 @@ namespace osu.Game.Overlays.Settings { Margin = new MarginPadding { - Top = header_size + header_margin + Top = header_size }, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 30), AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, }; @@ -70,7 +68,7 @@ namespace osu.Game.Overlays.Settings { Padding = new MarginPadding { - Top = 20 + border_size, + Top = margin + border_size, Bottom = 10, }, RelativeSizeAxes = Axes.X, @@ -82,7 +80,11 @@ namespace osu.Game.Overlays.Settings Font = OsuFont.GetFont(size: header_size), Text = Header, Colour = colours.Yellow, - Margin = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS } + Margin = new MarginPadding + { + Left = SettingsPanel.CONTENT_MARGINS, + Right = SettingsPanel.CONTENT_MARGINS + } }, FlowContent } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index b096c146a6..1b82d973e9 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Settings FlowContent = new FillFlowContainer { Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), + Spacing = new Vector2(0, 8), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, }; @@ -53,7 +53,7 @@ namespace osu.Game.Overlays.Settings new OsuSpriteText { Text = Header.ToUpperInvariant(), - Margin = new MarginPadding { Bottom = 10, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, + Margin = new MarginPadding { Vertical = 30, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, Font = OsuFont.GetFont(weight: FontWeight.Bold), }, FlowContent diff --git a/osu.Game/Overlays/Settings/Sidebar.cs b/osu.Game/Overlays/Settings/Sidebar.cs index 031ecaae46..4ca6e2ec42 100644 --- a/osu.Game/Overlays/Settings/Sidebar.cs +++ b/osu.Game/Overlays/Settings/Sidebar.cs @@ -9,9 +9,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.Settings { @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.Settings { new Box { - Colour = Color4.Black, + Colour = OsuColour.Gray(0.02f), RelativeSizeAxes = Axes.Both, }, new SidebarScrollContainer diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index e1bcdbbaf0..7bd84dbc6c 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -23,10 +23,11 @@ namespace osu.Game.Overlays { new GeneralSection(), new GraphicsSection(), - new GameplaySection(), new AudioSection(), - new SkinSection(), new InputSection(createSubPanel(new KeyBindingPanel())), + new UserInterfaceSection(), + new GameplaySection(), + new SkinSection(), new OnlineSection(), new MaintenanceSection(), new DebugSection(), @@ -61,7 +62,6 @@ namespace osu.Game.Overlays switch (state.NewValue) { case Visibility.Visible: - Background.FadeTo(0.9f, 300, Easing.OutQuint); Sidebar?.FadeColour(Color4.DarkGray, 300, Easing.OutQuint); SectionsContainer.FadeOut(300, Easing.OutQuint); @@ -69,7 +69,6 @@ namespace osu.Game.Overlays break; case Visibility.Hidden: - Background.FadeTo(0.6f, 500, Easing.OutQuint); Sidebar?.FadeColour(Color4.White, 300, Easing.OutQuint); SectionsContainer.FadeIn(500, Easing.OutQuint); diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 2948231c4b..7a5a586f67 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; @@ -72,8 +73,8 @@ namespace osu.Game.Overlays Origin = Anchor.TopRight, Scale = new Vector2(2, 1), // over-extend to the left for transitions RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.6f, + Colour = OsuColour.Gray(0.05f), + Alpha = 1, }, SectionsContainer = new SettingsSectionsContainer { @@ -214,7 +215,7 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); // no null check because the usage of this class is strict - HeaderBackground.Alpha = -ExpandableHeader.Y / ExpandableHeader.LayoutSize.Y * 0.5f; + HeaderBackground.Alpha = -ExpandableHeader.Y / ExpandableHeader.LayoutSize.Y; } } } diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index b90aa6863a..35852f60ea 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -442,6 +442,9 @@ namespace osu.Game.Rulesets.Edit public abstract SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); + public virtual SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => + new SnapResult(screenSpacePosition, null); + public abstract float GetBeatSnapDistanceAt(double referenceTime); public abstract float DurationToDistance(double referenceTime, double duration); diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs index cce631464f..4664f3808c 100644 --- a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs @@ -8,12 +8,22 @@ namespace osu.Game.Rulesets.Edit public interface IPositionSnapProvider { /// - /// Given a position, find a valid time snap. + /// Given a position, find a valid time and position snap. /// + /// + /// This call should be equivalent to running with any additional logic that can be performed without the time immutability restriction. + /// /// The screen-space position to be snapped. /// The time and position post-snapping. SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); + /// + /// Given a position, find a value position snap, restricting time to its input value. + /// + /// The screen-space position to be snapped. + /// The position post-snapping. Time will always be null. + SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition); + /// /// Retrieves the distance between two points within a timing point that are one beat length apart. /// diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index d986b71380..c0eb891f5e 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -85,10 +85,10 @@ namespace osu.Game.Rulesets.Edit } /// - /// Updates the position of this to a new screen-space position. + /// Updates the time and position of this based on the provided snap information. /// /// The snap result information. - public virtual void UpdatePosition(SnapResult result) + public virtual void UpdateTimeAndPosition(SnapResult result) { if (!PlacementActive) HitObject.StartTime = result.Time ?? EditorClock?.CurrentTime ?? Time.Current; diff --git a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs new file mode 100644 index 0000000000..d94346cb72 --- /dev/null +++ b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs @@ -0,0 +1,75 @@ +// 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.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Rulesets.Judgements +{ + public class DefaultJudgementPiece : CompositeDrawable, IAnimatableJudgement + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } + + [Resolved] + private OsuColour colours { get; set; } + + public DefaultJudgementPiece(HitResult result) + { + Result = result; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + JudgementText = new OsuSpriteText + { + Text = Result.GetDescription().ToUpperInvariant(), + Colour = colours.ForHitResult(Result), + Font = OsuFont.Numeric.With(size: 20), + Scale = new Vector2(0.85f, 1), + } + }; + } + + public virtual void PlayAnimation() + { + switch (Result) + { + 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; + + default: + this.ScaleTo(0.9f); + this.ScaleTo(1, 500, Easing.OutElastic); + break; + } + + this.FadeOutFromOne(800); + } + + public Drawable GetAboveHitObjectsProxiedContent() => null; + } +} diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index d24c81536e..da9bb8a09d 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -1,20 +1,17 @@ // 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 JetBrains.Annotations; -using osuTK; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Judgements { @@ -25,25 +22,26 @@ namespace osu.Game.Rulesets.Judgements { private const float judgement_size = 128; - [Resolved] - private OsuColour colours { get; set; } - public JudgementResult Result { get; private set; } + public DrawableHitObject JudgedObject { get; private set; } - protected Container JudgementBody { get; private set; } - protected SpriteText JudgementText { get; private set; } + public override bool RemoveCompletedTransforms => false; - private SkinnableDrawable bodyDrawable; + protected SkinnableDrawable JudgementBody { get; private set; } + + private readonly Container aboveHitObjectsContent; /// /// Duration of initial fade in. /// + [Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")] protected virtual double FadeInDuration => 100; /// /// Duration to wait until fade out begins. Defaults to . /// + [Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")] protected virtual double FadeOutDelay => FadeInDuration; /// @@ -61,6 +59,12 @@ namespace osu.Game.Rulesets.Judgements { Size = new Vector2(judgement_size); Origin = Anchor.Centre; + + AddInternal(aboveHitObjectsContent = new Container + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both + }); } [BackgroundDependencyLoader] @@ -69,15 +73,38 @@ namespace osu.Game.Rulesets.Judgements prepareDrawables(); } + public Drawable GetProxyAboveHitObjectsContent() => aboveHitObjectsContent.CreateProxy(); + + /// + /// Apply top-level animations to the current judgement when successfully hit. + /// If displaying components which require lifetime extensions, manually adjusting is required. + /// + /// + /// For animating the actual "default skin" judgement itself, it is recommended to use . + /// This allows applying animations which don't affect custom skins. + /// protected virtual void ApplyHitAnimations() { - JudgementBody.ScaleTo(0.9f); - JudgementBody.ScaleTo(1, 500, Easing.OutElastic); - - this.Delay(FadeOutDelay).FadeOut(400); } - public virtual void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) + /// + /// Apply top-level animations to the current judgement when missed. + /// If displaying components which require lifetime extensions, manually adjusting is required. + /// + /// + /// For animating the actual "default skin" judgement itself, it is recommended to use . + /// This allows applying animations which don't affect custom skins. + /// + protected virtual void ApplyMissAnimations() + { + } + + /// + /// Associate a new result / object with this judgement. Should be called when retrieving a judgement from a pool. + /// + /// The applicable judgement. + /// The drawable object. + public void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) { Result = result; JudgedObject = judgedObject; @@ -89,36 +116,52 @@ namespace osu.Game.Rulesets.Judgements Debug.Assert(Result != null); + runAnimation(); + } + + private void runAnimation() + { + // is a no-op if the drawables are already in a correct state. prepareDrawables(); - bodyDrawable.ResetAnimation(); + // undo any transforms applies in ApplyMissAnimations/ApplyHitAnimations to get a sane initial state. + ApplyTransformsAt(double.MinValue, true); + ClearTransforms(true); - this.FadeInFromZero(FadeInDuration, Easing.OutQuint); - JudgementBody.ScaleTo(1); - JudgementBody.RotateTo(0); - JudgementBody.MoveTo(Vector2.Zero); + LifetimeStart = Result.TimeAbsolute; - switch (Result.Type) + using (BeginAbsoluteSequence(Result.TimeAbsolute, true)) { - case HitResult.None: - break; + // not sure if this should remain going forward. + JudgementBody.ResetAnimation(); - case HitResult.Miss: - JudgementBody.ScaleTo(1.6f); - JudgementBody.ScaleTo(1, 100, Easing.In); + switch (Result.Type) + { + case HitResult.None: + break; - JudgementBody.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); - JudgementBody.RotateTo(40, 800, Easing.InQuint); + case HitResult.Miss: + ApplyMissAnimations(); + break; - this.Delay(600).FadeOut(200); - break; + default: + ApplyHitAnimations(); + break; + } - default: - ApplyHitAnimations(); - break; + if (JudgementBody.Drawable is IAnimatableJudgement animatable) + { + var drawableAnimation = (Drawable)animatable; + + animatable.PlayAnimation(); + + // a derived version of DrawableJudgement may be proposing a lifetime. + // if not adjusted (or the skinned portion requires greater bounds than calculated) use the skinned source's lifetime. + double lastTransformTime = drawableAnimation.LatestTransformEndTime; + if (LifetimeEnd == double.MaxValue || lastTransformTime > LifetimeEnd) + LifetimeEnd = lastTransformTime; + } } - - Expire(true); } private HitResult? currentDrawableType; @@ -127,6 +170,7 @@ namespace osu.Game.Rulesets.Judgements { var type = Result?.Type ?? HitResult.Perfect; //TODO: better default type from ruleset + // todo: this should be removed once judgements are always pooled. if (type == currentDrawableType) return; @@ -134,21 +178,38 @@ namespace osu.Game.Rulesets.Judgements if (JudgementBody != null) RemoveInternal(JudgementBody); - AddInternal(JudgementBody = new Container + AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponent(type), _ => + CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = bodyDrawable = new SkinnableDrawable(new GameplaySkinComponent(type), _ => JudgementText = new OsuSpriteText - { - Text = type.GetDescription().ToUpperInvariant(), - Font = OsuFont.Numeric.With(size: 20), - Colour = colours.ForHitResult(type), - Scale = new Vector2(0.85f, 1), - }, confineMode: ConfineMode.NoScaling) }); + JudgementBody.OnSkinChanged += () => + { + // on a skin change, the child component will update but not get correctly triggered to play its animation (or proxy the newly created content). + // we need to trigger a reinitialisation to make things right. + proxyContent(); + runAnimation(); + }; + + proxyContent(); + currentDrawableType = type; + + void proxyContent() + { + aboveHitObjectsContent.Clear(); + + if (JudgementBody.Drawable is IAnimatableJudgement animatable) + { + var proxiedContent = animatable.GetAboveHitObjectsProxiedContent(); + if (proxiedContent != null) + aboveHitObjectsContent.Add(proxiedContent); + } + } } + + protected virtual Drawable CreateDefaultJudgement(HitResult result) => new DefaultJudgementPiece(result); } } diff --git a/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs b/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs new file mode 100644 index 0000000000..b38b83b534 --- /dev/null +++ b/osu.Game/Rulesets/Judgements/IAnimatableJudgement.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 JetBrains.Annotations; +using osu.Framework.Graphics; + +namespace osu.Game.Rulesets.Judgements +{ + /// + /// A skinnable judgement element which supports playing an animation from the current point in time. + /// + public interface IAnimatableJudgement : IDrawable + { + /// + /// Start the animation for this judgement from the current point in time. + /// + void PlayAnimation(); + + /// + /// Get proxied content which should be displayed above all hitobjects. + /// + [CanBeNull] + Drawable GetAboveHitObjectsProxiedContent(); + } +} diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index c6f3930029..1290e8136c 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -2,17 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using Humanizer; -using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Graphics; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModEasy : Mod, IApplicableToDifficulty, IApplicableFailOverride, IApplicableToHealthProcessor + public abstract class ModEasy : Mod, IApplicableToDifficulty { public override string Name => "Easy"; public override string Acronym => "EZ"; @@ -22,49 +18,17 @@ namespace osu.Game.Rulesets.Mods public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) }; - [SettingSource("Extra Lives", "Number of extra lives")] - public Bindable Retries { get; } = new BindableInt(2) - { - MinValue = 0, - MaxValue = 10 - }; - - public override string SettingDescription => Retries.IsDefault ? string.Empty : $"{"lives".ToQuantity(Retries.Value)}"; - - private int retries; - - private BindableNumber health; - - public void ReadFromDifficulty(BeatmapDifficulty difficulty) + public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty) { } - public void ApplyToDifficulty(BeatmapDifficulty difficulty) + public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty) { const float ratio = 0.5f; difficulty.CircleSize *= ratio; difficulty.ApproachRate *= ratio; difficulty.DrainRate *= ratio; difficulty.OverallDifficulty *= ratio; - - retries = Retries.Value; - } - - public bool PerformFail() - { - if (retries == 0) return true; - - health.Value = health.MaxValue; - retries--; - - return false; - } - - public bool RestartOnFail => false; - - public void ApplyToHealthProcessor(HealthProcessor healthProcessor) - { - health = healthProcessor.Health.GetBoundCopy(); } } } diff --git a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs new file mode 100644 index 0000000000..2ac0f59d84 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Humanizer; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModEasyWithExtraLives : ModEasy, IApplicableFailOverride, IApplicableToHealthProcessor + { + [SettingSource("Extra Lives", "Number of extra lives")] + public Bindable Retries { get; } = new BindableInt(2) + { + MinValue = 0, + MaxValue = 10 + }; + + public override string SettingDescription => Retries.IsDefault ? string.Empty : $"{"lives".ToQuantity(Retries.Value)}"; + + private int retries; + + private BindableNumber health; + + public override void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + base.ApplyToDifficulty(difficulty); + retries = Retries.Value; + } + + public bool PerformFail() + { + if (retries == 0) return true; + + health.Value = health.MaxValue; + retries--; + + return false; + } + + public bool RestartOnFail => false; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + health = healthProcessor.Health.GetBoundCopy(); + } + } +} diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index ca49ed9e75..644c67ea59 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -10,7 +10,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Logging; using osu.Framework.Threading; @@ -43,6 +42,12 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public HitObject HitObject { get; private set; } + /// + /// The parenting , if any. + /// + [CanBeNull] + protected internal DrawableHitObject ParentHitObject { get; internal set; } + /// /// The colour used for various elements of this DrawableHitObject. /// @@ -74,6 +79,11 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public event Action OnRevertResult; + /// + /// Invoked when a new nested hit object is created by . + /// + internal event Action OnNestedDrawableCreated; + /// /// Whether a visual indicator should be displayed when a scoring result occurs. /// @@ -123,6 +133,12 @@ namespace osu.Game.Rulesets.Objects.Drawables private readonly Bindable state = new Bindable(); + /// + /// The state of this . + /// + /// + /// For pooled hitobjects, is recommended to be used instead for better editor/rewinding support. + /// public IBindable State => state; /// @@ -139,7 +155,10 @@ namespace osu.Game.Rulesets.Objects.Drawables [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } - private Container samplesContainer; + /// + /// Whether the initialization logic in has applied. + /// + internal bool IsInitialized; /// /// Creates a new . @@ -159,7 +178,7 @@ namespace osu.Game.Rulesets.Objects.Drawables config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds); // Explicit non-virtual function call. - base.AddInternal(samplesContainer = new Container { RelativeSizeAxes = Axes.Both }); + base.AddInternal(Samples = new PausableSkinnableSound()); } protected override void LoadAsyncComplete() @@ -174,7 +193,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.LoadComplete(); - comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); + comboIndexBindable.BindValueChanged(_ => UpdateComboColour(), true); updateState(ArmedState.Idle, true); } @@ -214,18 +233,25 @@ namespace osu.Game.Rulesets.Objects.Drawables foreach (var h in HitObject.NestedHitObjects) { - var drawableNested = pooledObjectProvider?.GetPooledDrawableRepresentation(h) + var pooledDrawableNested = pooledObjectProvider?.GetPooledDrawableRepresentation(h, this); + var drawableNested = pooledDrawableNested ?? CreateNestedHitObject(h) ?? throw new InvalidOperationException($"{nameof(CreateNestedHitObject)} returned null for {h.GetType().ReadableName()}."); + // Only invoke the event for non-pooled DHOs, otherwise the event will be fired by the playfield. + if (pooledDrawableNested == null) + OnNestedDrawableCreated?.Invoke(drawableNested); + drawableNested.OnNewResult += onNewResult; drawableNested.OnRevertResult += onRevertResult; drawableNested.ApplyCustomUpdateState += onApplyCustomUpdateState; + // This is only necessary for non-pooled DHOs. For pooled DHOs, this is handled inside GetPooledDrawableRepresentation(). + // Must be done before the nested DHO is added to occur before the nested Apply()! + drawableNested.ParentHitObject = this; + nestedHitObjects.Value.Add(drawableNested); AddNestedHitObject(drawableNested); - - drawableNested.OnParentReceived(this); } StartTimeBindable.BindTo(HitObject.StartTimeBindable); @@ -239,12 +265,19 @@ namespace osu.Game.Rulesets.Objects.Drawables HitObject.DefaultsApplied += onDefaultsApplied; - OnApply(hitObject); + OnApply(); HitObjectApplied?.Invoke(this); - // If not loaded, the state update happens in LoadComplete(). Otherwise, the update is scheduled to allow for lifetime updates. + // If not loaded, the state update happens in LoadComplete(). if (IsLoaded) - Schedule(() => updateState(ArmedState.Idle, true)); + { + if (Result.IsHit) + updateState(ArmedState.Hit, true); + else if (Result.HasResult) + updateState(ArmedState.Miss, true); + else + updateState(ArmedState.Idle, true); + } hasHitObjectApplied = true; } @@ -269,6 +302,9 @@ namespace osu.Game.Rulesets.Objects.Drawables // In order to stop this needless update, the event is unbound and re-bound as late as possible in Apply(). samplesBindable.CollectionChanged -= onSamplesChanged; + // Release the samples for other hitobjects to use. + Samples.Samples = null; + if (nestedHitObjects.IsValueCreated) { foreach (var obj in nestedHitObjects.Value) @@ -284,9 +320,10 @@ namespace osu.Game.Rulesets.Objects.Drawables HitObject.DefaultsApplied -= onDefaultsApplied; - OnFree(HitObject); + OnFree(); HitObject = null; + ParentHitObject = null; Result = null; lifetimeEntry = null; @@ -309,24 +346,14 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// Invoked for this to take on any values from a newly-applied . /// - /// The being applied. - protected virtual void OnApply(HitObject hitObject) + protected virtual void OnApply() { } /// /// Invoked for this to revert any values previously taken on from the currently-applied . /// - /// The currently-applied . - protected virtual void OnFree(HitObject hitObject) - { - } - - /// - /// Invoked when this receives a new parenting . - /// - /// The parenting . - protected virtual void OnParentReceived(DrawableHitObject parent) + protected virtual void OnFree() { } @@ -335,9 +362,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// protected virtual void LoadSamples() { - samplesContainer.Clear(); - Samples = null; - var samples = GetSamples().ToArray(); if (samples.Length <= 0) @@ -349,7 +373,7 @@ namespace osu.Game.Rulesets.Objects.Drawables + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); } - samplesContainer.Add(Samples = new PausableSkinnableSound(samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)))); + Samples.Samples = samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); } private void onSamplesChanged(object sender, NotifyCollectionChangedEventArgs e) => LoadSamples(); @@ -427,7 +451,7 @@ namespace osu.Game.Rulesets.Objects.Drawables state.Value = newState; if (LifetimeEnd == double.MaxValue && (state.Value != ArmedState.Idle || HitObject.HitWindows == null)) - Expire(); + LifetimeEnd = Math.Max(LatestTransformEndTime, HitStateUpdateTime + (Samples?.Length ?? 0)); // apply any custom state overrides ApplyCustomUpdateState?.Invoke(this, newState); @@ -439,6 +463,8 @@ namespace osu.Game.Rulesets.Objects.Drawables private void clearExistingStateTransforms() { base.ApplyTransformsAt(double.MinValue, true); + + // has to call this method directly (not ClearTransforms) to bypass the local ClearTransformsAfter override. base.ClearTransformsAfter(double.MinValue, true); } @@ -502,7 +528,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.SkinChanged(skin, allowFallback); - updateComboColour(); + UpdateComboColour(); ApplySkin(skin, allowFallback); @@ -510,13 +536,12 @@ namespace osu.Game.Rulesets.Objects.Drawables updateState(State.Value, true); } - private void updateComboColour() + protected void UpdateComboColour() { - if (!(HitObject is IHasComboInformation)) return; + if (!(HitObject is IHasComboInformation combo)) return; - var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value; - - AccentColour.Value = GetComboColour(comboColours); + var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); + AccentColour.Value = combo.GetComboColour(comboColours); } /// @@ -527,6 +552,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// This will only be called if the implements . /// /// A list of combo colours provided by the beatmap or skin. Can be null if not available. + [Obsolete("Unused. Implement IHasComboInformation and IHasComboInformation.GetComboColour() on the HitObject model instead.")] // Can be removed 20210527 protected virtual Color4 GetComboColour(IReadOnlyList comboColours) { if (!(HitObject is IHasComboInformation combo)) @@ -682,6 +708,18 @@ namespace osu.Game.Rulesets.Objects.Drawables UpdateResult(false); } + /// + /// The maximum offset from the end time of at which this can be judged. + /// The time offset of will be clamped to this value during . + /// + /// Defaults to the miss window of . + /// + /// + /// + /// This does not affect the time offset provided to invocations of . + /// + protected virtual double MaximumJudgementOffset => HitObject.HitWindows?.WindowFor(HitResult.Miss) ?? 0; + /// /// Applies the of this , notifying responders such as /// the of the . @@ -721,14 +759,7 @@ namespace osu.Game.Rulesets.Objects.Drawables $"{GetType().ReadableName()} applied an invalid hit result (was: {Result.Type}, expected: [{Result.Judgement.MinResult} ... {Result.Judgement.MaxResult}])."); } - // Ensure that the judgement is given a valid time offset, because this may not get set by the caller - var endTime = HitObject.GetEndTime(); - - Result.TimeOffset = Time.Current - endTime; - - double missWindow = HitObject.HitWindows.WindowFor(HitResult.Miss); - if (missWindow > 0) - Result.TimeOffset = Math.Min(Result.TimeOffset, missWindow); + Result.TimeOffset = Math.Min(MaximumJudgementOffset, Time.Current - HitObject.GetEndTime()); if (Result.HasResult) updateState(Result.IsHit ? ArmedState.Hit : ArmedState.Miss); @@ -750,8 +781,7 @@ namespace osu.Game.Rulesets.Objects.Drawables if (Judged) return false; - var endTime = HitObject.GetEndTime(); - CheckForResult(userTriggered, Time.Current - endTime); + CheckForResult(userTriggered, Time.Current - HitObject.GetEndTime()); return Judged; } diff --git a/osu.Game/Rulesets/Objects/Drawables/IDrawableHitObjectWithProxiedApproach.cs b/osu.Game/Rulesets/Objects/Drawables/IDrawableHitObjectWithProxiedApproach.cs deleted file mode 100644 index 8f4c95c634..0000000000 --- a/osu.Game/Rulesets/Objects/Drawables/IDrawableHitObjectWithProxiedApproach.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; - -namespace osu.Game.Rulesets.Objects.Drawables -{ - public interface IDrawableHitObjectWithProxiedApproach - { - Drawable ProxiedLayer { get; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 44b22033dc..72025de131 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -13,6 +13,7 @@ using JetBrains.Annotations; using osu.Framework.Utils; using osu.Game.Beatmaps.Legacy; using osu.Game.Skinning; +using osu.Game.Utils; namespace osu.Game.Rulesets.Objects.Legacy { @@ -427,62 +428,25 @@ namespace osu.Game.Rulesets.Objects.Legacy // Todo: This should return the normal SampleInfos if the specified sample file isn't found, but that's a pretty edge-case scenario if (!string.IsNullOrEmpty(bankInfo.Filename)) { - return new List - { - new FileHitSampleInfo - { - Filename = bankInfo.Filename, - Volume = bankInfo.Volume - } - }; + return new List { new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume) }; } var soundTypes = new List { - new LegacyHitSampleInfo - { - Bank = bankInfo.Normal, - Name = HitSampleInfo.HIT_NORMAL, - Volume = bankInfo.Volume, - CustomSampleBank = bankInfo.CustomSampleBank, + new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.Normal, 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 - IsLayered = type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal) - } + type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal)) }; if (type.HasFlag(LegacyHitSoundType.Finish)) - { - soundTypes.Add(new LegacyHitSampleInfo - { - Bank = bankInfo.Add, - Name = HitSampleInfo.HIT_FINISH, - Volume = bankInfo.Volume, - CustomSampleBank = bankInfo.CustomSampleBank - }); - } + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); if (type.HasFlag(LegacyHitSoundType.Whistle)) - { - soundTypes.Add(new LegacyHitSampleInfo - { - Bank = bankInfo.Add, - Name = HitSampleInfo.HIT_WHISTLE, - Volume = bankInfo.Volume, - CustomSampleBank = bankInfo.CustomSampleBank - }); - } + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); if (type.HasFlag(LegacyHitSoundType.Clap)) - { - soundTypes.Add(new LegacyHitSampleInfo - { - Bank = bankInfo.Add, - Name = HitSampleInfo.HIT_CLAP, - Volume = bankInfo.Volume, - CustomSampleBank = bankInfo.CustomSampleBank - }); - } + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); return soundTypes; } @@ -500,21 +464,11 @@ namespace osu.Game.Rulesets.Objects.Legacy public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } - public class LegacyHitSampleInfo : HitSampleInfo +#nullable enable + + public class LegacyHitSampleInfo : HitSampleInfo, IEquatable { - private int customSampleBank; - - public int CustomSampleBank - { - get => customSampleBank; - set - { - customSampleBank = value; - - if (value >= 2) - Suffix = value.ToString(); - } - } + public readonly int CustomSampleBank; /// /// Whether this hit sample is layered. @@ -523,18 +477,41 @@ namespace osu.Game.Rulesets.Objects.Legacy /// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled /// using the skin config option. /// - public bool IsLayered { get; set; } + public readonly bool IsLayered; + + public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int customSampleBank = 0, bool isLayered = false) + : base(name, bank, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) + { + CustomSampleBank = customSampleBank; + IsLayered = isLayered; + } + + 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, + Optional newIsLayered = default) + => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); + + public bool Equals(LegacyHitSampleInfo? other) + => base.Equals(other) && CustomSampleBank == other.CustomSampleBank && IsLayered == other.IsLayered; + + public override bool Equals(object? obj) + => obj is LegacyHitSampleInfo other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank, IsLayered); } - private class FileHitSampleInfo : LegacyHitSampleInfo + private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable { - public string Filename; + public readonly string Filename; - public FileHitSampleInfo() - { - // Make sure that the LegacyBeatmapSkin does not fall back to the user skin. + public FileHitSampleInfo(string filename, int volume) + // Force CSS=1 to make sure that the LegacyBeatmapSkin does not fall back to the user skin. // Note that this does not change the lookup names, as they are overridden locally. - CustomSampleBank = 1; + : base(string.Empty, customSampleBank: 1, volume: volume) + { + Filename = filename; } public override IEnumerable LookupNames => new[] @@ -542,6 +519,20 @@ namespace osu.Game.Rulesets.Objects.Legacy Filename, Path.ChangeExtension(Filename, null) }; + + 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)); + + public bool Equals(FileHitSampleInfo? other) + => base.Equals(other) && Filename == other.Filename; + + public override bool Equals(object? obj) + => obj is FileHitSampleInfo other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Filename); } + +#nullable disable } } diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index d8c6da86f9..ba38c7f77d 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -10,14 +10,6 @@ namespace osu.Game.Rulesets.Objects { public static class SliderEventGenerator { - [Obsolete("Use the overload with cancellation support instead.")] // can be removed 20201115 - // ReSharper disable once RedundantOverload.Global - public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, - double? legacyLastTickOffset) - { - return Generate(startTime, spanDuration, velocity, tickDistance, totalDistance, spanCount, legacyLastTickOffset, default); - } - // ReSharper disable once MethodOverloadWithOptionalParameter public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, double? legacyLastTickOffset, CancellationToken cancellationToken = default) diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 211c077d4f..4f66802079 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Bindables; +using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Types { @@ -35,5 +38,13 @@ namespace osu.Game.Rulesets.Objects.Types /// Whether this is the last object in the current combo. /// bool LastInCombo { get; set; } + + /// + /// Retrieves the colour of the combo described by this object from a set of possible combo colours. + /// Defaults to using to decide the colour. + /// + /// A list of possible combo colours provided by the beatmap or skin. + /// The colour of the combo described by this object. + Color4 GetComboColour([NotNull] IReadOnlyList comboColours) => comboColours.Count > 0 ? comboColours[ComboIndex % comboColours.Count] : Color4.White; } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 5fbda305c8..12e39d4fbf 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -105,7 +105,7 @@ namespace osu.Game.Rulesets.UI { Debug.Assert(!drawableMap.ContainsKey(entry)); - var drawable = pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject); + var drawable = pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null); if (drawable == null) throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}."); @@ -114,6 +114,7 @@ namespace osu.Game.Rulesets.UI bindStartTime(drawable); AddInternal(drawableMap[entry] = drawable, false); + OnAdd(drawable); HitObjectUsageBegan?.Invoke(entry.HitObject); } @@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.UI drawableMap.Remove(entry); + OnRemove(drawable); unbindStartTime(drawable); RemoveInternal(drawable); @@ -147,10 +149,12 @@ namespace osu.Game.Rulesets.UI hitObject.OnRevertResult += onRevertResult; AddInternal(hitObject); + OnAdd(hitObject); } public virtual bool Remove(DrawableHitObject hitObject) { + OnRemove(hitObject); if (!RemoveInternal(hitObject)) return false; @@ -178,6 +182,26 @@ namespace osu.Game.Rulesets.UI #endregion + /// + /// Invoked when a is added to this container. + /// + /// + /// This method is not invoked for nested s. + /// + protected virtual void OnAdd(DrawableHitObject drawableHitObject) + { + } + + /// + /// Invoked when a is removed from this container. + /// + /// + /// This method is not invoked for nested s. + /// + protected virtual void OnRemove(DrawableHitObject drawableHitObject) + { + } + public virtual void Clear(bool disposeChildren = true) { lifetimeManager.ClearEntries(); diff --git a/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs b/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs index 315926dfc6..2d700076d6 100644 --- a/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs +++ b/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs @@ -13,8 +13,9 @@ namespace osu.Game.Rulesets.UI /// Attempts to retrieve the poolable representation of a . /// /// The to retrieve the representation of. + /// The parenting , if any. /// The representing , or null if no poolable representation exists. [CanBeNull] - DrawableHitObject GetPooledDrawableRepresentation([NotNull] HitObject hitObject); + DrawableHitObject GetPooledDrawableRepresentation([NotNull] HitObject hitObject, [CanBeNull] DrawableHitObject parent); } } diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 82ec653f31..b4e0025351 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -8,19 +8,24 @@ using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; +using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; using osuTK; +using System.Diagnostics; namespace osu.Game.Rulesets.UI { [Cached(typeof(IPooledHitObjectProvider))] - public abstract class Playfield : CompositeDrawable, IPooledHitObjectProvider + [Cached(typeof(IPooledSampleProvider))] + public abstract class Playfield : CompositeDrawable, IPooledHitObjectProvider, IPooledSampleProvider { /// /// Invoked when a is judged. @@ -80,6 +85,12 @@ namespace osu.Game.Rulesets.UI /// public readonly BindableBool DisplayJudgements = new BindableBool(true); + [Resolved(CanBeNull = true)] + private IReadOnlyList mods { get; set; } + + [Resolved] + private ISampleStore sampleStore { get; set; } + /// /// Creates a new . /// @@ -96,9 +107,6 @@ namespace osu.Game.Rulesets.UI })); } - [Resolved(CanBeNull = true)] - private IReadOnlyList mods { get; set; } - [BackgroundDependencyLoader] private void load() { @@ -113,6 +121,16 @@ namespace osu.Game.Rulesets.UI } } + private void onNewDrawableHitObject(DrawableHitObject d) + { + d.OnNestedDrawableCreated += onNewDrawableHitObject; + + OnNewDrawableHitObject(d); + + Debug.Assert(!d.IsInitialized); + d.IsInitialized = true; + } + /// /// Performs post-processing tasks (if any) after all DrawableHitObjects are loaded into this Playfield. /// @@ -124,6 +142,9 @@ namespace osu.Game.Rulesets.UI /// The DrawableHitObject to add. public virtual void Add(DrawableHitObject h) { + if (!h.IsInitialized) + onNewDrawableHitObject(h); + HitObjectContainer.Add(h); OnHitObjectAdded(h.HitObject); } @@ -157,6 +178,17 @@ namespace osu.Game.Rulesets.UI { } + /// + /// Invoked before a new is added to this . + /// It is invoked only once even if the drawable is pooled and used multiple times for different s. + /// + /// + /// This is also invoked for nested s. + /// + protected virtual void OnNewDrawableHitObject(DrawableHitObject drawableHitObject) + { + } + /// /// The cursor currently being used by this . May be null if no cursor is provided. /// @@ -298,7 +330,7 @@ namespace osu.Game.Rulesets.UI AddInternal(pool); } - DrawableHitObject IPooledHitObjectProvider.GetPooledDrawableRepresentation(HitObject hitObject) + DrawableHitObject IPooledHitObjectProvider.GetPooledDrawableRepresentation(HitObject hitObject, DrawableHitObject parent) { var lookupType = hitObject.GetType(); @@ -321,10 +353,12 @@ namespace osu.Game.Rulesets.UI { var dho = (DrawableHitObject)d; - // If this is the first time this DHO is being used (not loaded), then apply the DHO mods. - // This is done before Apply() so that the state is updated once when the hitobject is applied. - if (!dho.IsLoaded) + if (!dho.IsInitialized) { + onNewDrawableHitObject(dho); + + // If this is the first time this DHO is being used, then apply the DHO mods. + // This is done before Apply() so that the state is updated once when the hitobject is applied. foreach (var m in mods.OfType()) m.ApplyToDrawableHitObjects(dho.Yield()); } @@ -332,10 +366,34 @@ namespace osu.Game.Rulesets.UI if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry)) lifetimeEntryMap[hitObject] = entry = CreateLifetimeEntry(hitObject); + dho.ParentHitObject = parent; dho.Apply(hitObject, entry); }); } + private readonly Dictionary> samplePools = new Dictionary>(); + + public PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo) + { + if (!samplePools.TryGetValue(sampleInfo, out var existingPool)) + AddInternal(samplePools[sampleInfo] = existingPool = new DrawableSamplePool(sampleInfo, 1)); + + return existingPool.Get(); + } + + private class DrawableSamplePool : DrawablePool + { + private readonly ISampleInfo sampleInfo; + + public DrawableSamplePool(ISampleInfo sampleInfo, int initialSize, int? maximumSize = null) + : base(initialSize, maximumSize) + { + this.sampleInfo = sampleInfo; + } + + protected override PoolableSkinnableSample CreateNewDrawable() => base.CreateNewDrawable().With(d => d.Apply(sampleInfo)); + } + #endregion #region Editor logic diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index bf64175468..3a5e3c098f 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -2,13 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Layout; -using osu.Framework.Threading; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -19,7 +16,16 @@ namespace osu.Game.Rulesets.UI.Scrolling { private readonly IBindable timeRange = new BindableDouble(); private readonly IBindable direction = new Bindable(); - private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); + + /// + /// Hit objects which require lifetime computation in the next update call. + /// + private readonly HashSet toComputeLifetime = new HashSet(); + + /// + /// A set containing all which have an up-to-date layout. + /// + private readonly HashSet layoutComputed = new HashSet(); [Resolved] private IScrollingInfo scrollingInfo { get; set; } @@ -27,10 +33,6 @@ namespace osu.Game.Rulesets.UI.Scrolling // Responds to changes in the layout. When the layout changes, all hit object states must be recomputed. private readonly LayoutValue layoutCache = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); - // A combined cache across all hit object states to reduce per-update iterations. - // When invalidated, one or more (but not necessarily all) hitobject states must be re-validated. - private readonly Cached combinedObjCache = new Cached(); - public ScrollingHitObjectContainer() { RelativeSizeAxes = Axes.Both; @@ -48,37 +50,12 @@ namespace osu.Game.Rulesets.UI.Scrolling timeRange.ValueChanged += _ => layoutCache.Invalidate(); } - public override void Add(DrawableHitObject hitObject) - { - combinedObjCache.Invalidate(); - hitObject.DefaultsApplied += onDefaultsApplied; - base.Add(hitObject); - } - - public override bool Remove(DrawableHitObject hitObject) - { - var result = base.Remove(hitObject); - - if (result) - { - combinedObjCache.Invalidate(); - hitObjectInitialStateCache.Remove(hitObject); - - hitObject.DefaultsApplied -= onDefaultsApplied; - } - - return result; - } - public override void Clear(bool disposeChildren = true) { - foreach (var h in Objects) - h.DefaultsApplied -= onDefaultsApplied; - base.Clear(disposeChildren); - combinedObjCache.Invalidate(); - hitObjectInitialStateCache.Clear(); + toComputeLifetime.Clear(); + layoutComputed.Clear(); } /// @@ -173,15 +150,40 @@ namespace osu.Game.Rulesets.UI.Scrolling } } - private void onDefaultsApplied(DrawableHitObject drawableObject) + protected override void OnAdd(DrawableHitObject drawableHitObject) => onAddRecursive(drawableHitObject); + + protected override void OnRemove(DrawableHitObject drawableHitObject) => onRemoveRecursive(drawableHitObject); + + private void onAddRecursive(DrawableHitObject hitObject) { - // The cache may not exist if the hitobject state hasn't been computed yet (e.g. if the hitobject was added + defaults applied in the same frame). - // In such a case, combinedObjCache will take care of updating the hitobject. - if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var state)) - { - combinedObjCache.Invalidate(); - state.Cache.Invalidate(); - } + invalidateHitObject(hitObject); + + hitObject.DefaultsApplied += invalidateHitObject; + + foreach (var nested in hitObject.NestedHitObjects) + onAddRecursive(nested); + } + + private void onRemoveRecursive(DrawableHitObject hitObject) + { + toComputeLifetime.Remove(hitObject); + layoutComputed.Remove(hitObject); + + hitObject.DefaultsApplied -= invalidateHitObject; + + foreach (var nested in hitObject.NestedHitObjects) + onRemoveRecursive(nested); + } + + /// + /// Make this lifetime and layout computed in next update. + /// + private void invalidateHitObject(DrawableHitObject hitObject) + { + // Lifetime computation is delayed until next update because + // when the hit object is not pooled this container is not loaded here and `scrollLength` cannot be computed. + toComputeLifetime.Add(hitObject); + layoutComputed.Remove(hitObject); } private float scrollLength; @@ -192,17 +194,18 @@ namespace osu.Game.Rulesets.UI.Scrolling if (!layoutCache.IsValid) { - foreach (var state in hitObjectInitialStateCache.Values) - state.Cache.Invalidate(); - combinedObjCache.Invalidate(); + toComputeLifetime.Clear(); + + foreach (var hitObject in Objects) + { + if (hitObject.HitObject != null) + toComputeLifetime.Add(hitObject); + } + + layoutComputed.Clear(); scrollingInfo.Algorithm.Reset(); - layoutCache.Validate(); - } - - if (!combinedObjCache.IsValid) - { switch (direction.Value) { case ScrollingDirection.Up: @@ -215,32 +218,24 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } - foreach (var obj in Objects) - { - if (!hitObjectInitialStateCache.TryGetValue(obj, out var state)) - state = hitObjectInitialStateCache[obj] = new InitialState(new Cached()); - - if (state.Cache.IsValid) - continue; - - state.ScheduledComputation?.Cancel(); - state.ScheduledComputation = computeInitialStateRecursive(obj); - - computeLifetimeStartRecursive(obj); - - state.Cache.Validate(); - } - - combinedObjCache.Validate(); + layoutCache.Validate(); } - } - private void computeLifetimeStartRecursive(DrawableHitObject hitObject) - { - hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); + foreach (var hitObject in toComputeLifetime) + hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); - foreach (var obj in hitObject.NestedHitObjects) - computeLifetimeStartRecursive(obj); + toComputeLifetime.Clear(); + + // only AliveObjects need to be considered for layout (reduces overhead in the case of scroll speed changes). + foreach (var obj in AliveObjects) + { + if (layoutComputed.Contains(obj)) + continue; + + updateLayoutRecursive(obj); + + layoutComputed.Add(obj); + } } private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) @@ -271,7 +266,7 @@ namespace osu.Game.Rulesets.UI.Scrolling return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); } - private ScheduledDelegate computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => + private void updateLayoutRecursive(DrawableHitObject hitObject) { if (hitObject.HitObject is IHasDuration e) { @@ -291,12 +286,12 @@ namespace osu.Game.Rulesets.UI.Scrolling foreach (var obj in hitObject.NestedHitObjects) { - computeInitialStateRecursive(obj); + updateLayoutRecursive(obj); // Nested hitobjects don't need to scroll, but they do need accurate positions updatePosition(obj, hitObject.HitObject.StartTime); } - }); + } protected override void UpdateAfterChildrenLife() { @@ -328,19 +323,5 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } } - - private class InitialState - { - [NotNull] - public readonly Cached Cache; - - [CanBeNull] - public ScheduledDelegate ScheduledComputation; - - public InitialState(Cached cache) - { - Cache = cache; - } - } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index 9dac3f4de1..2b75f93f9e 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.UI.Scrolling { protected readonly IBindable Direction = new Bindable(); + public new ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)base.HitObjectContainer; + [Resolved] protected IScrollingInfo ScrollingInfo { get; private set; } @@ -27,14 +29,12 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// Given a position in screen space, return the time within this column. /// - public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => - ((ScrollingHitObjectContainer)HitObjectContainer).TimeAtScreenSpacePosition(screenSpacePosition); + public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => HitObjectContainer.TimeAtScreenSpacePosition(screenSpacePosition); /// /// Given a time, return the screen space position within this column. /// - public virtual Vector2 ScreenSpacePositionAtTime(double time) - => ((ScrollingHitObjectContainer)HitObjectContainer).ScreenSpacePositionAtTime(time); + public virtual Vector2 ScreenSpacePositionAtTime(double time) => HitObjectContainer.ScreenSpacePositionAtTime(time); protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 53b6e14940..0b45bd5597 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private IEditorChangeHandler changeHandler { get; set; } [Resolved] - private EditorClock editorClock { get; set; } + protected EditorClock EditorClock { get; private set; } [Resolved] protected EditorBeatmap Beatmap { get; private set; } @@ -118,8 +118,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - protected virtual Container CreateSelectionBlueprintContainer() => - new Container { RelativeSizeAxes = Axes.Both }; + protected virtual Container CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; /// /// Creates a which outlines s and handles movement of selections. @@ -171,7 +170,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint) return false; - editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); + EditorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); return true; } @@ -188,7 +187,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.Button == MouseButton.Right) return false; - if (movementBlueprint != null) + if (movementBlueprints != null) { isDraggingBlueprint = true; changeHandler?.BeginChange(); @@ -300,7 +299,7 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionBlueprints.Remove(blueprint); - if (movementBlueprint == blueprint) + if (movementBlueprints?.Contains(blueprint) == true) finishSelectionMovement(); OnBlueprintRemoved(hitObject); @@ -338,7 +337,8 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether a selection was performed. private bool beginClickSelection(MouseButtonEvent e) { - foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren) + // Iterate from the top of the input stack (blueprints closest to the front of the screen first). + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse()) { if (!blueprint.IsHovered) continue; @@ -381,7 +381,7 @@ namespace osu.Game.Screens.Edit.Compose.Components case SelectionState.Selected: // if the editor is playing, we generally don't want to deselect objects even if outside the selection area. - if (!editorClock.IsRunning && !isValidForSelection()) + if (!EditorClock.IsRunning && !isValidForSelection()) blueprint.Deselect(); break; } @@ -424,8 +424,8 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Movement - private Vector2? movementBlueprintOriginalPosition; - private SelectionBlueprint movementBlueprint; + private Vector2[] movementBlueprintOriginalPositions; + private SelectionBlueprint[] movementBlueprints; private bool isDraggingBlueprint; /// @@ -442,8 +442,8 @@ namespace osu.Game.Screens.Edit.Compose.Components return; // Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject - movementBlueprint = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First(); - movementBlueprintOriginalPosition = movementBlueprint.ScreenSpaceSelectionPoint; // todo: unsure if correct + movementBlueprints = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).ToArray(); + movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray(); } /// @@ -453,36 +453,50 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether a movement was active. private bool moveCurrentSelection(DragEvent e) { - if (movementBlueprint == null) + if (movementBlueprints == null) return false; if (snapProvider == null) return true; - Debug.Assert(movementBlueprintOriginalPosition != null); + Debug.Assert(movementBlueprintOriginalPositions != null); - HitObject draggedObject = movementBlueprint.HitObject; + Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // check for positional snap for every object in selection (for things like object-object snapping) + for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++) + { + var testPosition = movementBlueprintOriginalPositions[i] + distanceTravelled; + + var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition); + + if (positionalResult.ScreenSpacePosition == testPosition) continue; + + // attempt to move the objects, and abort any time based snapping if we can. + if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], positionalResult.ScreenSpacePosition))) + return true; + } + + // if no positional snapping could be performed, try unrestricted snapping from the earliest + // hitobject in the selection. // The final movement position, relative to movementBlueprintOriginalPosition. - Vector2 movePosition = movementBlueprintOriginalPosition.Value + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled; // Retrieve a snapped position. var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition); // Move the hitobjects. - if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition))) + if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), result.ScreenSpacePosition))) return true; if (result.Time.HasValue) { // Apply the start time at the newly snapped-to position - double offset = result.Time.Value - draggedObject.StartTime; + double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime; foreach (HitObject obj in Beatmap.SelectedHitObjects) - { obj.StartTime += offset; - Beatmap.Update(obj); - } } return true; @@ -494,11 +508,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether a movement was active. private bool finishSelectionMovement() { - if (movementBlueprint == null) + if (movementBlueprints == null) return false; - movementBlueprintOriginalPosition = null; - movementBlueprint = null; + movementBlueprintOriginalPositions = null; + movementBlueprints = null; return true; } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 0d2e2360b1..c09b935f28 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -101,7 +101,7 @@ namespace osu.Game.Screens.Edit.Compose.Components case TernaryState.True: if (existingSample == null) - samples.Add(new HitSampleInfo { Name = sampleName }); + samples.Add(new HitSampleInfo(sampleName)); break; } } @@ -157,7 +157,10 @@ namespace osu.Game.Screens.Edit.Compose.Components { var snapResult = Composer.SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); - currentPlacement.UpdatePosition(snapResult); + // if no time was found from positional snapping, we should still quantize to the beat. + snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); + + currentPlacement.UpdateTimeAndPosition(snapResult); } #endregion @@ -209,7 +212,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (blueprint != null) { // doing this post-creations as adding the default hit sample should be the case regardless of the ruleset. - blueprint.HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_NORMAL }); + blueprint.HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); placementBlueprintContainer.Child = currentPlacement = blueprint; diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs new file mode 100644 index 0000000000..9e95fe4fa1 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.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.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A container for ordered by their start times. + /// + public sealed class HitObjectOrderedSelectionContainer : Container + { + public override void Add(SelectionBlueprint drawable) + { + base.Add(drawable); + bindStartTime(drawable); + } + + public override bool Remove(SelectionBlueprint drawable) + { + if (!base.Remove(drawable)) + return false; + + unbindStartTime(drawable); + return true; + } + + public override void Clear(bool disposeChildren) + { + base.Clear(disposeChildren); + unbindAllStartTimes(); + } + + private readonly Dictionary startTimeMap = new Dictionary(); + + private void bindStartTime(SelectionBlueprint blueprint) + { + var bindable = blueprint.HitObject.StartTimeBindable.GetBoundCopy(); + + bindable.BindValueChanged(_ => + { + if (LoadState >= LoadState.Ready) + SortInternal(); + }); + + startTimeMap[blueprint] = bindable; + } + + private void unbindStartTime(SelectionBlueprint blueprint) + { + startTimeMap[blueprint].UnbindAll(); + startTimeMap.Remove(blueprint); + } + + private void unbindAllStartTimes() + { + foreach (var kvp in startTimeMap) + kvp.Value.UnbindAll(); + startTimeMap.Clear(); + } + + protected override int Compare(Drawable x, Drawable y) + { + var xObj = (SelectionBlueprint)x; + var yObj = (SelectionBlueprint)y; + + // Put earlier blueprints towards the end of the list, so they handle input first + int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime); + return i == 0 ? CompareReverseChildID(x, y) : i; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index adf22a3370..788b485449 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -328,7 +328,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (h.Samples.Any(s => s.Name == sampleName)) continue; - h.Samples.Add(new HitSampleInfo { Name = sampleName }); + h.Samples.Add(new HitSampleInfo(sampleName)); } EditorBeatmap.EndChange(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index f6675902fc..20836c0e68 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -224,6 +224,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// public double VisibleRange => track.Length / Zoom; + public SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => + new SnapResult(screenSpacePosition, null); + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index eef02e61a6..2f14c607c2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -201,7 +201,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public TimelineSelectionBlueprintContainer() { - AddInternal(new TimelinePart(Content = new Container { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); + AddInternal(new TimelinePart(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 975433d407..657c5834b2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,8 +18,8 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -28,32 +27,26 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class TimelineHitObjectBlueprint : SelectionBlueprint { - private readonly Circle circle; + private const float thickness = 5; + private const float shadow_radius = 5; + private const float circle_size = 24; + + public Action OnDragHandled; [UsedImplicitly] private readonly Bindable startTime; - public Action OnDragHandled; + private Bindable indexInCurrentComboBindable; + private Bindable comboIndexBindable; + private readonly Circle circle; private readonly DragBar dragBar; - private readonly List shadowComponents = new List(); - - private DrawableHitObject drawableHitObject; - - private Bindable comboColour; - private readonly Container mainComponents; - private readonly OsuSpriteText comboIndexText; - private Bindable comboIndex; - - private const float thickness = 5; - - private const float shadow_radius = 5; - - private const float circle_size = 24; + [Resolved] + private ISkinSource skin { get; set; } public TimelineHitObjectBlueprint(HitObject hitObject) : base(hitObject) @@ -152,46 +145,42 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline updateShadows(); } - [BackgroundDependencyLoader(true)] - private void load(HitObjectComposer composer) - { - if (composer != null) - { - // best effort to get the drawable representation for grabbing colour and what not. - drawableHitObject = composer.HitObjects.FirstOrDefault(d => d.HitObject == HitObject); - } - } - protected override void LoadComplete() { base.LoadComplete(); if (HitObject is IHasComboInformation comboInfo) { - comboIndex = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); - comboIndex.BindValueChanged(combo => - { - comboIndexText.Text = (combo.NewValue + 1).ToString(); - }, true); + indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); + indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); + + comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); + comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); + + skin.SourceChanged += updateComboColour; } + } - if (drawableHitObject != null) - { - comboColour = drawableHitObject.AccentColour.GetBoundCopy(); - comboColour.BindValueChanged(colour => - { - if (HitObject is IHasDuration) - mainComponents.Colour = ColourInfo.GradientHorizontal(drawableHitObject.AccentColour.Value, Color4.White); - else - mainComponents.Colour = drawableHitObject.AccentColour.Value; + private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); - var col = mainComponents.Colour.TopLeft.Linear; - float brightness = col.R + col.G + col.B; + private void updateComboColour() + { + if (!(HitObject is IHasComboInformation combo)) + return; - // decide the combo index colour based on brightness? - comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White; - }, true); - } + var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); + var comboColour = combo.GetComboColour(comboColours); + + if (HitObject is IHasDuration) + mainComponents.Colour = ColourInfo.GradientHorizontal(comboColour, Color4.White); + else + mainComponents.Colour = comboColour; + + var col = mainComponents.Colour.TopLeft.Linear; + float brightness = col.R + col.G + col.B; + + // decide the combo index colour based on brightness? + comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White; } protected override void Update() diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index 724256af8b..fb11b859a7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -25,6 +25,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private BindableBeatDivisor beatDivisor { get; set; } + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + [Resolved] private OsuColour colours { get; set; } @@ -38,7 +41,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load() { - beatDivisor.BindValueChanged(_ => tickCache.Invalidate()); + beatDivisor.BindValueChanged(_ => invalidateTicks()); + + if (changeHandler != null) + // currently this is the best way to handle any kind of timing changes. + changeHandler.OnStateChange += invalidateTicks; + } + + private void invalidateTicks() + { + tickCache.Invalidate(); } /// @@ -165,5 +177,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return point; } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (changeHandler != null) + changeHandler.OnStateChange -= invalidateTicks; + } } } diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index d9948aa23c..c297a03dbf 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -32,7 +33,8 @@ namespace osu.Game.Screens.Edit.Compose composer = ruleset?.CreateHitObjectComposer(); // make the composer available to the timeline and other components in this screen. - dependencies.CacheAs(composer); + if (composer != null) + dependencies.CacheAs(composer); return dependencies; } @@ -42,6 +44,21 @@ namespace osu.Game.Screens.Edit.Compose if (ruleset == null || composer == null) return new ScreenWhiteBox.UnderConstructionMessage(ruleset == null ? "This beatmap" : $"{ruleset.Description}'s composer"); + return wrapSkinnableContent(composer); + } + + protected override Drawable CreateTimelineContent() + { + if (ruleset == null || composer == null) + return base.CreateTimelineContent(); + + return wrapSkinnableContent(new TimelineBlueprintContainer(composer)); + } + + private Drawable wrapSkinnableContent(Drawable content) + { + Debug.Assert(ruleset != null); + var beatmapSkinProvider = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); // the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation @@ -50,9 +67,7 @@ namespace osu.Game.Screens.Edit.Compose // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. - return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(composer)); + return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(content)); } - - protected override Drawable CreateTimelineContent() => composer == null ? base.CreateTimelineContent() : new TimelineBlueprintContainer(composer); } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 85467d3bbb..ca7e5fbf20 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -106,6 +106,12 @@ namespace osu.Game.Screens.Edit [BackgroundDependencyLoader] private void load(OsuColour colours, GameHost host, OsuConfigManager config) { + if (Beatmap.Value is DummyWorkingBeatmap) + { + isNewBeatmap = true; + Beatmap.Value = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); + } + beatDivisor.Value = Beatmap.Value.BeatmapInfo.BeatDivisor; beatDivisor.BindValueChanged(divisor => Beatmap.Value.BeatmapInfo.BeatDivisor = divisor.NewValue); @@ -122,12 +128,6 @@ namespace osu.Game.Screens.Edit // todo: remove caching of this and consume via editorBeatmap? dependencies.Cache(beatDivisor); - if (Beatmap.Value is DummyWorkingBeatmap) - { - isNewBeatmap = true; - Beatmap.Value = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); - } - try { playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); @@ -375,6 +375,9 @@ namespace osu.Game.Screens.Edit protected override bool OnScroll(ScrollEvent e) { + if (e.ControlPressed || e.AltPressed || e.SuperPressed) + return false; + const double precision = 1; double scrollComponent = e.ScrollDelta.X + e.ScrollDelta.Y; diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs index 62187aed24..2dcb416a03 100644 --- a/osu.Game/Screens/Edit/EditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -76,7 +76,7 @@ namespace osu.Game.Screens.Edit var newState = stream.ToArray(); // if the previous state is binary equal we don't need to push a new one, unless this is the initial state. - if (savedStates.Count > 0 && newState.SequenceEqual(savedStates.Last())) return; + if (savedStates.Count > 0 && newState.SequenceEqual(savedStates[currentState])) return; if (currentState < savedStates.Count - 1) savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1); diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index aa1d57db31..897ddc6955 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -37,8 +37,8 @@ namespace osu.Game.Screens.Edit.Setup Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, - MinValue = 2, - MaxValue = 7, + MinValue = 0, + MaxValue = 10, Precision = 0.1f, } }, diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index b802b3405a..6e2737256a 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -3,10 +3,12 @@ using System; using System.IO; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -21,6 +23,9 @@ namespace osu.Game.Screens.Edit.Setup private readonly IBindable currentFile = new Bindable(); + [Resolved] + private SectionsContainer sectionsContainer { get; set; } + public FileChooserLabelledTextBox() { currentFile.BindValueChanged(onFileSelected); @@ -47,14 +52,16 @@ namespace osu.Game.Screens.Edit.Setup public void DisplayFileChooser() { - Target.Child = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions) + FileSelector fileSelector; + + Target.Child = fileSelector = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions) { RelativeSizeAxes = Axes.X, Height = 400, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, CurrentFile = { BindTarget = currentFile } }; + + sectionsContainer.ScrollTo(fileSelector); } internal class FileChooserOsuTextBox : OsuTextBox diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index e81646456f..abb83f894a 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -113,8 +113,7 @@ namespace osu.Game.Screens.Menu RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Alpha = 0.5f, - AccentColour = Color4.DarkBlue, + Colour = Color4.DarkBlue, Size = new Vector2(0.96f) }, new Circle diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index ebbb19636c..01b2a98c6e 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; -using osu.Game.Graphics; using System; using System.Collections.Generic; using JetBrains.Annotations; @@ -20,13 +19,14 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Utils; +using osu.Framework.Extensions.Color4Extensions; namespace osu.Game.Screens.Menu { /// /// A visualiser that reacts to music coming from beatmaps. /// - public class LogoVisualisation : Drawable, IHasAccentColour + public class LogoVisualisation : Drawable { private readonly IBindable beatmap = new Bindable(); @@ -67,8 +67,6 @@ namespace osu.Game.Screens.Menu private int indexOffset; - public Color4 AccentColour { get; set; } - /// /// The relative movement of bars based on input amplification. Defaults to 1. /// @@ -176,7 +174,8 @@ namespace osu.Game.Screens.Menu // Assuming the logo is a circle, we don't need a second dimension. private float size; - private Color4 colour; + private static readonly Color4 transparent_white = Color4.White.Opacity(0.2f); + private float[] audioData; private readonly QuadBatch vertexBatch = new QuadBatch(100, 10); @@ -193,7 +192,6 @@ namespace osu.Game.Screens.Menu shader = Source.shader; texture = Source.texture; size = Source.DrawSize.X; - colour = Source.AccentColour; audioData = Source.frequencyAmplitudes; } @@ -206,7 +204,7 @@ namespace osu.Game.Screens.Menu Vector2 inflation = DrawInfo.MatrixInverse.ExtractScale().Xy; ColourInfo colourInfo = DrawColourInfo.Colour; - colourInfo.ApplyChild(colour); + colourInfo.ApplyChild(transparent_white); if (audioData != null) { diff --git a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs index 5eb3f1efa0..92add458f9 100644 --- a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs +++ b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs @@ -7,7 +7,6 @@ using osu.Game.Online.API; using osu.Game.Users; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; namespace osu.Game.Screens.Menu { @@ -28,12 +27,10 @@ namespace osu.Game.Screens.Menu private void updateColour() { - Color4 defaultColour = Color4.White.Opacity(0.2f); - if (user.Value?.IsSupporter ?? false) - AccentColour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? defaultColour; + Colour = skin.Value.GetConfig(GlobalSkinColours.MenuGlow)?.Value ?? Color4.White; else - AccentColour = defaultColour; + Colour = Color4.White; } } } diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 4515ee8ed0..68d23e1a32 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -81,6 +81,8 @@ namespace osu.Game.Screens.Menu set => rippleContainer.FadeTo(value ? 1 : 0, transition_length, Easing.OutQuint); } + private const float visualizer_default_alpha = 0.5f; + private readonly Box flashLayer; private readonly Container impactContainer; @@ -144,7 +146,7 @@ namespace osu.Game.Screens.Menu RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, - Alpha = 0.5f, + Alpha = visualizer_default_alpha, Size = new Vector2(0.96f) }, new Container @@ -282,8 +284,7 @@ namespace osu.Game.Screens.Menu this.Delay(early_activation).Schedule(() => sampleBeat.Play()); logoBeatContainer - .ScaleTo(1 - 0.02f * amplitudeAdjust, early_activation, Easing.Out) - .Then() + .ScaleTo(1 - 0.02f * amplitudeAdjust, early_activation, Easing.Out).Then() .ScaleTo(1, beatLength * 2, Easing.OutQuint); ripple.ClearTransforms(); @@ -296,15 +297,13 @@ namespace osu.Game.Screens.Menu { flashLayer.ClearTransforms(); flashLayer - .FadeTo(0.2f * amplitudeAdjust, early_activation, Easing.Out) - .Then() + .FadeTo(0.2f * amplitudeAdjust, early_activation, Easing.Out).Then() .FadeOut(beatLength); visualizer.ClearTransforms(); visualizer - .FadeTo(0.9f * amplitudeAdjust, early_activation, Easing.Out) - .Then() - .FadeTo(0.5f, beatLength); + .FadeTo(visualizer_default_alpha * 1.8f * amplitudeAdjust, early_activation, Easing.Out).Then() + .FadeTo(visualizer_default_alpha, beatLength); } } diff --git a/osu.Game/Screens/Multi/Components/OverlinedPlaylistHeader.cs b/osu.Game/Screens/Multi/Components/OverlinedPlaylistHeader.cs new file mode 100644 index 0000000000..5552c1cb72 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/OverlinedPlaylistHeader.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 osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.Multi.Components +{ + public class OverlinedPlaylistHeader : OverlinedHeader + { + public OverlinedPlaylistHeader() + : base("Playlist") + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Playlist.BindCollectionChanged((_, __) => Details.Value = Playlist.GetTotalDuration(), true); + } + } +} diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs index 77fbd606f4..dfee278e87 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs @@ -67,7 +67,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components } } }, - new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] { new OverlinedPlaylistHeader(), }, new Drawable[] { new DrawableRoomPlaylist(false, false) diff --git a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs index caefc194b1..668a373d80 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Specialized; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -69,6 +70,7 @@ namespace osu.Game.Screens.Multi.Match.Components private OsuSpriteText typeLabel; private LoadingLayer loadingLayer; private DrawableRoomPlaylist playlist; + private OsuSpriteText playlistLength; [Resolved(CanBeNull = true)] private IRoomManager manager { get; set; } @@ -229,6 +231,15 @@ namespace osu.Game.Screens.Multi.Match.Components playlist = new DrawableRoomPlaylist(true, true) { RelativeSizeAxes = Axes.Both } }, new Drawable[] + { + playlistLength = new OsuSpriteText + { + Margin = new MarginPadding { Vertical = 5 }, + Colour = colours.Yellow, + Font = OsuFont.GetFont(size: 12), + } + }, + new Drawable[] { new PurpleTriangleButton { @@ -243,6 +254,7 @@ namespace osu.Game.Screens.Multi.Match.Components { new Dimension(), new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), } } }, @@ -315,6 +327,7 @@ namespace osu.Game.Screens.Multi.Match.Components Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue, true); playlist.Items.BindTo(Playlist); + Playlist.BindCollectionChanged(onPlaylistChanged, true); } protected override void Update() @@ -324,6 +337,9 @@ namespace osu.Game.Screens.Multi.Match.Components ApplyButton.Enabled.Value = hasValidSettings; } + private void onPlaylistChanged(object sender, NotifyCollectionChangedEventArgs e) => + playlistLength.Text = $"Length: {Playlist.GetTotalDuration()}"; + private bool hasValidSettings => RoomID.Value == null && NameField.Text.Length > 0 && Playlist.Count > 0; private void apply() diff --git a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs index 2cbe215a39..2f8aad4e65 100644 --- a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs +++ b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs @@ -135,7 +135,7 @@ namespace osu.Game.Screens.Multi.Match RelativeSizeAxes = Axes.Both, Content = new[] { - new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] { new OverlinedPlaylistHeader(), }, new Drawable[] { new DrawableRoomPlaylistWithResults diff --git a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs index 04da943a10..0efa9c5196 100644 --- a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs +++ b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs @@ -92,7 +92,7 @@ namespace osu.Game.Screens.Multi.Play protected override ResultsScreen CreateResults(ScoreInfo score) { Debug.Assert(roomId.Value != null); - return new TimeshiftResultsScreen(score, roomId.Value.Value, playlistItem); + return new TimeshiftResultsScreen(score, roomId.Value.Value, playlistItem, true); } protected override ScoreInfo CreateScore() diff --git a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs index 8da6a530a8..3623208fa7 100644 --- a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs +++ b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs @@ -32,7 +32,7 @@ namespace osu.Game.Screens.Multi.Ranking [Resolved] private IAPIProvider api { get; set; } - public TimeshiftResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + public TimeshiftResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry) : base(score, allowRetry) { this.roomId = roomId; diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index e83dded075..50195d571c 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -9,7 +9,6 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -19,7 +18,6 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play.HUD; using osuTK; -using osuTK.Input; namespace osu.Game.Screens.Play { @@ -181,7 +179,7 @@ namespace osu.Game.Screens.Play notificationOverlay?.Post(new SimpleNotification { - Text = @"The score overlay is currently disabled. You can toggle this by pressing Shift+Tab." + Text = $"The score overlay is currently disabled. You can toggle this by pressing {config.LookupKeyBindings(GlobalAction.ToggleInGameInterface)}." }); } @@ -273,37 +271,6 @@ namespace osu.Game.Screens.Play Progress.BindDrawableRuleset(drawableRuleset); } - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Repeat) return false; - - if (e.ShiftPressed) - { - switch (e.Key) - { - case Key.Tab: - switch (configVisibilityMode.Value) - { - case HUDVisibilityMode.Never: - configVisibilityMode.Value = HUDVisibilityMode.HideDuringGameplay; - break; - - case HUDVisibilityMode.HideDuringGameplay: - configVisibilityMode.Value = HUDVisibilityMode.Always; - break; - - case HUDVisibilityMode.Always: - configVisibilityMode.Value = HUDVisibilityMode.Never; - break; - } - - return true; - } - } - - return base.OnKeyDown(e); - } - protected virtual SkinnableAccuracyCounter CreateAccuracyCounter() => new SkinnableAccuracyCounter(); protected virtual SkinnableScoreCounter CreateScoreCounter() => new SkinnableScoreCounter(); @@ -377,6 +344,24 @@ namespace osu.Game.Screens.Play holdingForHUD = true; updateVisibility(); return true; + + case GlobalAction.ToggleInGameInterface: + switch (configVisibilityMode.Value) + { + case HUDVisibilityMode.Never: + configVisibilityMode.Value = HUDVisibilityMode.HideDuringGameplay; + break; + + case HUDVisibilityMode.HideDuringGameplay: + configVisibilityMode.Value = HUDVisibilityMode.Always; + break; + + case HUDVisibilityMode.Always: + configVisibilityMode.Value = HUDVisibilityMode.Never; + break; + } + + return true; } return false; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index b94f0a5062..7979b635aa 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -339,7 +339,11 @@ namespace osu.Game.Screens.Play AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, IsCounting = false }, - RequestSeek = GameplayClockContainer.Seek, + RequestSeek = time => + { + GameplayClockContainer.Seek(time); + GameplayClockContainer.Start(); + }, Anchor = Anchor.Centre, Origin = Anchor.Centre }, @@ -545,7 +549,7 @@ namespace osu.Game.Screens.Play protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; - protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score); + protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, true); #region Fail Logic diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 3a4298f22d..294d116f51 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; using osu.Game.Scoring; using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { - public class ReplayPlayer : Player + public class ReplayPlayer : Player, IKeyBindingHandler { protected readonly Score Score; @@ -35,5 +37,24 @@ namespace osu.Game.Screens.Play return Score.ScoreInfo; } + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.TogglePauseReplay: + if (GameplayClockContainer.IsPaused.Value) + GameplayClockContainer.Start(); + else + GameplayClockContainer.Stop(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index b123757ded..92b304de91 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -133,6 +133,9 @@ namespace osu.Game.Screens.Play switch (action) { case GlobalAction.SkipCutscene: + if (!button.Enabled.Value) + return false; + button.Click(); return true; } diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs index 56ccfd2253..dabdf0a139 100644 --- a/osu.Game/Screens/Play/SpectatorResultsScreen.cs +++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Play public class SpectatorResultsScreen : SoloResultsScreen { public SpectatorResultsScreen(ScoreInfo score) - : base(score) + : base(score, false) { } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index f8bdf0140c..887e7ec8a9 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -16,6 +16,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.API; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Play; @@ -57,7 +58,7 @@ namespace osu.Game.Screens.Ranking private readonly bool allowRetry; - protected ResultsScreen(ScoreInfo score, bool allowRetry = true) + protected ResultsScreen(ScoreInfo score, bool allowRetry) { Score = score; this.allowRetry = allowRetry; @@ -149,7 +150,12 @@ namespace osu.Game.Screens.Ranking }; if (Score != null) - ScorePanelList.AddScore(Score, true); + { + // only show flair / animation when arriving after watching a play that isn't autoplay. + bool shouldFlair = player != null && !Score.Mods.Any(m => m is ModAutoplay); + + ScorePanelList.AddScore(Score, shouldFlair); + } if (player != null && allowRetry) { diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 9cf2e6757a..76b549da1a 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Ranking [Resolved] private RulesetStore rulesets { get; set; } - public SoloResultsScreen(ScoreInfo score, bool allowRetry = true) + public SoloResultsScreen(ScoreInfo score, bool allowRetry) : base(score, allowRetry) { } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 83631fd383..d76f0abb9e 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -91,7 +91,7 @@ namespace osu.Game.Screens.Select /// public bool BeatmapSetsLoaded { get; private set; } - private readonly CarouselScrollContainer scroll; + protected readonly CarouselScrollContainer Scroll; private IEnumerable beatmapSets => root.Children.OfType(); @@ -112,9 +112,9 @@ namespace osu.Game.Screens.Select if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet)) selectedBeatmapSet = null; - ScrollableContent.Clear(false); + Scroll.Clear(false); itemsCache.Invalidate(); - scrollPositionCache.Invalidate(); + ScrollToSelected(); // apply any pending filter operation that may have been delayed (see applyActiveCriteria's scheduling behaviour when BeatmapSetsLoaded is false). FlushPendingFilterOperations(); @@ -130,9 +130,7 @@ namespace osu.Game.Screens.Select private readonly List visibleItems = new List(); private readonly Cached itemsCache = new Cached(); - private readonly Cached scrollPositionCache = new Cached(); - - protected readonly Container ScrollableContent; + private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None; public Bindable RightClickScrollingEnabled = new Bindable(); @@ -155,17 +153,12 @@ namespace osu.Game.Screens.Select InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = scroll = new CarouselScrollContainer + Children = new Drawable[] { - Masking = false, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + setPool, + Scroll = new CarouselScrollContainer { - setPool, - ScrollableContent = new Container - { - RelativeSizeAxes = Axes.X, - } + RelativeSizeAxes = Axes.Both, } } }; @@ -180,7 +173,7 @@ namespace osu.Game.Screens.Select config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); - RightClickScrollingEnabled.ValueChanged += enabled => scroll.RightMouseScrollbar = enabled.NewValue; + RightClickScrollingEnabled.ValueChanged += enabled => Scroll.RightMouseScrollbar = enabled.NewValue; RightClickScrollingEnabled.TriggerChange(); itemUpdated = beatmaps.ItemUpdated.GetBoundCopy(); @@ -421,12 +414,12 @@ namespace osu.Game.Screens.Select /// /// The position of the lower visible bound with respect to the current scroll position. /// - private float visibleBottomBound => scroll.Current + DrawHeight + BleedBottom; + private float visibleBottomBound => Scroll.Current + DrawHeight + BleedBottom; /// /// The position of the upper visible bound with respect to the current scroll position. /// - private float visibleUpperBound => scroll.Current - BleedTop; + private float visibleUpperBound => Scroll.Current - BleedTop; public void FlushPendingFilterOperations() { @@ -468,8 +461,8 @@ namespace osu.Game.Screens.Select root.Filter(activeCriteria); itemsCache.Invalidate(); - if (alwaysResetScrollPosition || !scroll.UserScrolling) - ScrollToSelected(); + if (alwaysResetScrollPosition || !Scroll.UserScrolling) + ScrollToSelected(true); } } @@ -478,7 +471,12 @@ namespace osu.Game.Screens.Select /// /// Scroll to the current . /// - public void ScrollToSelected() => scrollPositionCache.Invalidate(); + /// + /// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels. + /// This should be true for operations like filtering - where panels are changing visibility state - to avoid large jumps in animation. + /// + public void ScrollToSelected(bool immediate = false) => + pendingScrollOperation = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard; #region Key / button selection logic @@ -488,12 +486,12 @@ namespace osu.Game.Screens.Select { case Key.Left: if (!e.Repeat) - beginRepeatSelection(() => SelectNext(-1, true), e.Key); + beginRepeatSelection(() => SelectNext(-1), e.Key); return true; case Key.Right: if (!e.Repeat) - beginRepeatSelection(() => SelectNext(1, true), e.Key); + beginRepeatSelection(() => SelectNext(), e.Key); return true; } @@ -580,6 +578,11 @@ namespace osu.Game.Screens.Select if (revalidateItems) updateYPositions(); + // if there is a pending scroll action we apply it without animation and transfer the difference in position to the panels. + // this is intentionally applied before updating the visible range below, to avoid animating new items (sourced from pool) from locations off-screen, as it looks bad. + if (pendingScrollOperation != PendingScrollOperation.None) + updateScrollPosition(); + // This data is consumed to find the currently displayable range. // This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn. var newDisplayRange = getDisplayRange(); @@ -594,7 +597,7 @@ namespace osu.Game.Screens.Select { var toDisplay = visibleItems.GetRange(displayedRange.first, displayedRange.last - displayedRange.first + 1); - foreach (var panel in ScrollableContent.Children) + foreach (var panel in Scroll.Children) { if (toDisplay.Remove(panel.Item)) { @@ -620,24 +623,14 @@ namespace osu.Game.Screens.Select panel.Depth = item.CarouselYPosition; panel.Y = item.CarouselYPosition; - ScrollableContent.Add(panel); + Scroll.Add(panel); } } } - // Finally, if the filtered items have changed, animate drawables to their new locations. - // This is common if a selected/collapsed state has changed. - if (revalidateItems) - { - foreach (DrawableCarouselItem panel in ScrollableContent.Children) - { - panel.MoveToY(panel.Item.CarouselYPosition, 800, Easing.OutQuint); - } - } - // Update externally controlled state of currently visible items (e.g. x-offset and opacity). // This is a per-frame update on all drawable panels. - foreach (DrawableCarouselItem item in ScrollableContent.Children) + foreach (DrawableCarouselItem item in Scroll.Children) { updateItem(item); @@ -670,14 +663,6 @@ namespace osu.Game.Screens.Select return (firstIndex, lastIndex); } - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - if (!scrollPositionCache.IsValid) - updateScrollPosition(); - } - private void beatmapRemoved(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) @@ -789,7 +774,8 @@ namespace osu.Game.Screens.Select } currentY += visibleHalfHeight; - ScrollableContent.Height = currentY; + + Scroll.ScrollContent.Height = currentY; if (BeatmapSetsLoaded && (selectedBeatmapSet == null || selectedBeatmap == null || selectedBeatmapSet.State.Value != CarouselItemState.Selected)) { @@ -809,12 +795,31 @@ namespace osu.Game.Screens.Select if (firstScroll) { // reduce movement when first displaying the carousel. - scroll.ScrollTo(scrollTarget.Value - 200, false); + Scroll.ScrollTo(scrollTarget.Value - 200, false); firstScroll = false; } - scroll.ScrollTo(scrollTarget.Value); - scrollPositionCache.Validate(); + switch (pendingScrollOperation) + { + case PendingScrollOperation.Standard: + Scroll.ScrollTo(scrollTarget.Value); + break; + + case PendingScrollOperation.Immediate: + // in order to simplify animation logic, rather than using the animated version of ScrollTo, + // we take the difference in scroll height and apply to all visible panels. + // this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer + // to enter clamp-special-case mode where it animates completely differently to normal. + float scrollChange = scrollTarget.Value - Scroll.Current; + + Scroll.ScrollTo(scrollTarget.Value, false); + + foreach (var i in Scroll.Children) + i.Y += scrollChange; + break; + } + + pendingScrollOperation = PendingScrollOperation.None; } } @@ -844,7 +849,7 @@ namespace osu.Game.Screens.Select /// For nested items, the parent of the item to be updated. private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null) { - Vector2 posInScroll = ScrollableContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); + Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); float itemDrawY = posInScroll.Y - visibleUpperBound; float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight); @@ -858,6 +863,13 @@ namespace osu.Game.Screens.Select item.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1)); } + private enum PendingScrollOperation + { + None, + Standard, + Immediate, + } + /// /// A carousel item strictly used for binary search purposes. /// @@ -889,7 +901,7 @@ namespace osu.Game.Screens.Select } } - private class CarouselScrollContainer : OsuScrollContainer + protected class CarouselScrollContainer : OsuScrollContainer { private bool rightMouseScrollBlocked; @@ -898,6 +910,15 @@ namespace osu.Game.Screens.Select /// public bool UserScrolling { get; private set; } + public CarouselScrollContainer() + { + // size is determined by the carousel itself, due to not all content necessarily being loaded. + ScrollContent.AutoSizeAxes = Axes.None; + + // the scroll container may get pushed off-screen by global screen changes, but we still want panels to display outside of the bounds. + Masking = false; + } + // ReSharper disable once OptionalParameterHierarchyMismatch 2020.3 EAP4 bug. (https://youtrack.jetbrains.com/issue/RSRP-481535?p=RIDER-51910) protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) { diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 7935debac7..bf045ed612 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -45,7 +45,7 @@ namespace osu.Game.Screens.Select.Carousel protected override CarouselItem GetNextToSelect() { - if (LastSelected == null) + if (LastSelected == null || LastSelected.Filtered.Value) { if (GetRecommendedBeatmap?.Invoke(Children.OfType().Where(b => !b.Filtered.Value).Select(b => b.Beatmap)) is BeatmapInfo recommended) return Children.OfType().First(b => b.Beatmap == recommended); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 93f95e76cc..b3c5d458d6 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Graphics.UserInterface; @@ -60,6 +61,25 @@ namespace osu.Game.Screens.Select.Carousel viewDetails = beatmapOverlay.FetchAndShowBeatmapSet; } + protected override void Update() + { + base.Update(); + + // position updates should not occur if the item is filtered away. + // this avoids panels flying across the screen only to be eventually off-screen or faded out. + if (!Item.Visible) + return; + + float targetY = Item.CarouselYPosition; + + if (Precision.AlmostEquals(targetY, Y)) + Y = targetY; + else + // algorithm for this is taken from ScrollContainer. + // while it doesn't necessarily need to match 1:1, as we are emulating scroll in some cases this feels most correct. + Y = (float)Interpolation.Lerp(targetY, Y, Math.Exp(-0.01 * Time.Elapsed)); + } + protected override void UpdateItem() { base.UpdateItem(); @@ -80,8 +100,14 @@ namespace osu.Game.Screens.Select.Carousel background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault())) { RelativeSizeAxes = Axes.Both, - }, 300), - mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 100), + }, 300) + { + RelativeSizeAxes = Axes.Both + }, + mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 100) + { + RelativeSizeAxes = Axes.Both + }, }; background.DelayedLoadComplete += fadeContentIn; diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index ee8825640c..50a61ed4c2 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -38,7 +38,7 @@ namespace osu.Game.Screens.Select } protected void PresentScore(ScoreInfo score) => - FinaliseSelection(score.Beatmap, score.Ruleset, () => this.Push(new SoloResultsScreen(score))); + FinaliseSelection(score.Beatmap, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false))); protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index b55c0694ef..f32011a27a 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -376,7 +376,7 @@ namespace osu.Game.Screens.Select if (selectionChangedDebounce?.Completed == false) { selectionChangedDebounce.RunTask(); - selectionChangedDebounce.Cancel(); // cancel the already scheduled task. + selectionChangedDebounce?.Cancel(); // cancel the already scheduled task. selectionChangedDebounce = null; } @@ -465,19 +465,30 @@ namespace osu.Game.Screens.Select void run() { + // clear pending task immediately to track any potential nested debounce operation. + selectionChangedDebounce = null; + Logger.Log($"updating selection with beatmap:{beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ID.ToString() ?? "null"}"); if (transferRulesetValue()) { Mods.Value = Array.Empty(); - // transferRulesetValue() may trigger a refilter. If the current selection does not match the new ruleset, we want to switch away from it. + // transferRulesetValue() may trigger a re-filter. If the current selection does not match the new ruleset, we want to switch away from it. // The default logic on WorkingBeatmap change is to switch to a matching ruleset (see workingBeatmapChanged()), but we don't want that here. // We perform an early selection attempt and clear out the beatmap selection to avoid a second ruleset change (revert). if (beatmap != null && !Carousel.SelectBeatmap(beatmap, false)) beatmap = null; } + if (selectionChangedDebounce != null) + { + // a new nested operation was started; switch to it for further selection. + // this avoids having two separate debounces trigger from the same source. + selectionChangedDebounce.RunTask(); + return; + } + // We may be arriving here due to another component changing the bindable Beatmap. // In these cases, the other component has already loaded the beatmap, so we don't need to do so again. if (!EqualityComparer.Default.Equals(beatmap, Beatmap.Value.BeatmapInfo)) diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs index 7e52bb8176..f627379a57 100644 --- a/osu.Game/Skinning/IAnimationTimeReference.cs +++ b/osu.Game/Skinning/IAnimationTimeReference.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.OpenGL.Textures; using osu.Framework.Timing; @@ -25,6 +26,6 @@ namespace osu.Game.Skinning /// /// The time which animations should be started from, relative to . /// - double AnimationStartTime { get; } + Bindable AnimationStartTime { get; } } } diff --git a/osu.Game/Skinning/IPooledSampleProvider.cs b/osu.Game/Skinning/IPooledSampleProvider.cs new file mode 100644 index 0000000000..40193d1a1a --- /dev/null +++ b/osu.Game/Skinning/IPooledSampleProvider.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 JetBrains.Annotations; +using osu.Game.Audio; + +namespace osu.Game.Skinning +{ + /// + /// Provides pooled samples to be used by s. + /// + internal interface IPooledSampleProvider + { + /// + /// Retrieves a from a pool. + /// + /// The describing the sample to retrieve. + /// The . + [CanBeNull] + PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo); + } +} diff --git a/osu.Game/Skinning/LegacyJudgementPieceNew.cs b/osu.Game/Skinning/LegacyJudgementPieceNew.cs new file mode 100644 index 0000000000..ca25efaa01 --- /dev/null +++ b/osu.Game/Skinning/LegacyJudgementPieceNew.cs @@ -0,0 +1,127 @@ +// 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.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Skinning +{ + public class LegacyJudgementPieceNew : CompositeDrawable, IAnimatableJudgement + { + private readonly HitResult result; + + private readonly LegacyJudgementPieceOld temporaryOldStyle; + + private readonly Drawable mainPiece; + + private readonly ParticleExplosion particles; + + public LegacyJudgementPieceNew(HitResult result, Func createMainDrawable, Texture particleTexture) + { + this.result = result; + + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + + InternalChildren = new[] + { + mainPiece = createMainDrawable().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }) + }; + + if (particleTexture != null) + { + AddInternal(particles = new ParticleExplosion(particleTexture, 150, 1600) + { + Size = new Vector2(140), + Depth = float.MaxValue, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + if (result != HitResult.Miss) + { + //new judgement shows old as a temporary effect + AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f) + { + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } + + public void PlayAnimation() + { + var animation = mainPiece as IFramedAnimation; + + animation?.GotoFrame(0); + + if (particles != null) + { + // start the particles already some way into their animation to break cluster away from centre. + using (particles.BeginDelayedSequence(-100, true)) + particles.Restart(); + } + + const double fade_in_length = 120; + const double fade_out_delay = 500; + const double fade_out_length = 600; + + this.FadeInFromZero(fade_in_length); + this.Delay(fade_out_delay).FadeOut(fade_out_length); + + // new style non-miss judgements show the original style temporarily, with additive colour. + if (temporaryOldStyle != null) + { + temporaryOldStyle.PlayAnimation(); + + temporaryOldStyle.Hide(); + temporaryOldStyle.Delay(-16) + .FadeTo(0.5f, 56, Easing.Out).Then() + .FadeOut(300); + } + + // legacy judgements don't play any transforms if they are an animation. + if (animation?.FrameCount > 1) + return; + + switch (result) + { + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + //todo: this only applies to osu! ruleset apparently. + this.MoveTo(new Vector2(0, -2)); + this.MoveToOffset(new Vector2(0, 20), fade_out_delay + fade_out_length, Easing.In); + + float rotation = RNG.NextSingle(-8.6f, 8.6f); + + this.RotateTo(0); + this.RotateTo(rotation, fade_in_length) + .Then().RotateTo(rotation * 2, fade_out_delay + fade_out_length - fade_in_length, Easing.In); + break; + + default: + mainPiece.ScaleTo(0.9f); + mainPiece.ScaleTo(1.05f, fade_out_delay + fade_out_length); + break; + } + } + + public Drawable GetAboveHitObjectsProxiedContent() => temporaryOldStyle?.CreateProxy(); // for new style judgements, only the old style temporary display is in front of objects. + } +} diff --git a/osu.Game/Skinning/LegacyJudgementPieceOld.cs b/osu.Game/Skinning/LegacyJudgementPieceOld.cs new file mode 100644 index 0000000000..5d74ab9ae3 --- /dev/null +++ b/osu.Game/Skinning/LegacyJudgementPieceOld.cs @@ -0,0 +1,75 @@ +// 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.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Skinning +{ + public class LegacyJudgementPieceOld : CompositeDrawable, IAnimatableJudgement + { + private readonly HitResult result; + + private readonly float finalScale; + + public LegacyJudgementPieceOld(HitResult result, Func createMainDrawable, float finalScale = 1f) + { + this.result = result; + this.finalScale = finalScale; + + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + + InternalChild = createMainDrawable(); + } + + public virtual void PlayAnimation() + { + var animation = InternalChild as IFramedAnimation; + + animation?.GotoFrame(0); + + const double fade_in_length = 120; + const double fade_out_delay = 500; + const double fade_out_length = 600; + + this.FadeInFromZero(fade_in_length); + this.Delay(fade_out_delay).FadeOut(fade_out_length); + + // legacy judgements don't play any transforms if they are an animation. + if (animation?.FrameCount > 1) + return; + + switch (result) + { + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + float rotation = RNG.NextSingle(-8.6f, 8.6f); + + this.RotateTo(0); + this.RotateTo(rotation, fade_in_length) + .Then().RotateTo(rotation * 2, fade_out_delay + fade_out_length - fade_in_length, Easing.In); + break; + + default: + + this.ScaleTo(0.6f).Then() + .ScaleTo(1.1f, fade_in_length * 0.8f).Then() + // this is actually correct to match stable; there were overlapping transforms. + .ScaleTo(0.9f).Delay(fade_in_length * 0.2f) + .ScaleTo(1.1f).ScaleTo(0.9f, fade_in_length * 0.2f).Then() + .ScaleTo(0.95f).ScaleTo(finalScale, fade_in_length * 0.2f); + break; + } + } + + public Drawable GetAboveHitObjectsProxiedContent() => CreateProxy(); + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index fb020f4e39..63a22eba62 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -371,19 +371,15 @@ namespace osu.Game.Skinning } case GameplaySkinComponent resultComponent: - switch (resultComponent.Component) + Func createDrawable = () => getJudgementAnimation(resultComponent.Component); + + // kind of wasteful that we throw this away, but should do for now. + if (createDrawable() != null) { - case HitResult.Miss: - return this.GetAnimation("hit0", true, false); - - case HitResult.Meh: - return this.GetAnimation("hit50", true, false); - - case HitResult.Ok: - return this.GetAnimation("hit100", true, false); - - case HitResult.Great: - return this.GetAnimation("hit300", true, false); + if (Configuration.LegacyVersion > 1) + return new LegacyJudgementPieceNew(resultComponent.Component, createDrawable, getParticleTexture(resultComponent.Component)); + else + return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); } break; @@ -392,6 +388,43 @@ namespace osu.Game.Skinning return this.GetAnimation(component.LookupName, false, false); } + private Texture getParticleTexture(HitResult result) + { + switch (result) + { + case HitResult.Meh: + return GetTexture("particle50"); + + case HitResult.Ok: + return GetTexture("particle100"); + + case HitResult.Great: + return GetTexture("particle300"); + } + + return null; + } + + private Drawable getJudgementAnimation(HitResult result) + { + switch (result) + { + case HitResult.Miss: + return this.GetAnimation("hit0", true, false); + + case HitResult.Meh: + return this.GetAnimation("hit50", true, false); + + case HitResult.Ok: + return this.GetAnimation("hit100", true, false); + + case HitResult.Great: + return this.GetAnimation("hit300", true, false); + } + + return null; + } + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { foreach (var name in getFallbackNames(componentName)) diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index 0ee02a2442..a7c084998d 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.OpenGL.Textures; @@ -70,6 +71,8 @@ namespace osu.Game.Skinning [Resolved(canBeNull: true)] private IAnimationTimeReference timeReference { get; set; } + private readonly Bindable animationStartTime = new BindableDouble(); + public SkinnableTextureAnimation(bool startAtCurrentTime = true) : base(startAtCurrentTime) { @@ -82,8 +85,18 @@ namespace osu.Game.Skinning if (timeReference != null) { Clock = timeReference.Clock; - PlaybackPosition = timeReference.Clock.CurrentTime - timeReference.AnimationStartTime; + animationStartTime.BindTo(timeReference.AnimationStartTime); } + + animationStartTime.BindValueChanged(_ => updatePlaybackPosition(), true); + } + + private void updatePlaybackPosition() + { + if (timeReference == null) + return; + + PlaybackPosition = timeReference.Clock.CurrentTime - timeReference.AnimationStartTime.Value; } } diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index 4f09aec0b6..4b6099e85f 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Threading; @@ -12,15 +14,21 @@ namespace osu.Game.Skinning { public class PausableSkinnableSound : SkinnableSound { + public double Length => !DrawableSamples.Any() ? 0 : DrawableSamples.Max(sample => sample.Length); + protected bool RequestedPlaying { get; private set; } - public PausableSkinnableSound(ISampleInfo hitSamples) - : base(hitSamples) + public PausableSkinnableSound() { } - public PausableSkinnableSound(IEnumerable hitSamples) - : base(hitSamples) + public PausableSkinnableSound([NotNull] IEnumerable samples) + : base(samples) + { + } + + public PausableSkinnableSound([NotNull] ISampleInfo sample) + : base(sample) { } diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs new file mode 100644 index 0000000000..19b96d6c60 --- /dev/null +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -0,0 +1,168 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; +using osu.Framework.Graphics.Containers; +using osu.Game.Audio; + +namespace osu.Game.Skinning +{ + /// + /// A sample corresponding to an that supports being pooled and responding to skin changes. + /// + public class PoolableSkinnableSample : SkinReloadableDrawable, IAggregateAudioAdjustment, IAdjustableAudioComponent + { + /// + /// The currently-loaded . + /// + [CanBeNull] + public DrawableSample Sample { get; private set; } + + private readonly AudioContainer sampleContainer; + private ISampleInfo sampleInfo; + + [Resolved] + private ISampleStore sampleStore { get; set; } + + /// + /// Creates a new with no applied . + /// An can be applied later via . + /// + public PoolableSkinnableSample() + { + InternalChild = sampleContainer = new AudioContainer { RelativeSizeAxes = Axes.Both }; + } + + /// + /// Creates a new with an applied . + /// + /// The to attach. + public PoolableSkinnableSample(ISampleInfo sampleInfo) + : this() + { + Apply(sampleInfo); + } + + /// + /// Applies an that describes the sample to retrieve. + /// Only one can ever be applied to a . + /// + /// The to apply. + /// If an has already been applied to this . + public void Apply(ISampleInfo sampleInfo) + { + if (this.sampleInfo != null) + throw new InvalidOperationException($"A {nameof(PoolableSkinnableSample)} cannot be applied multiple {nameof(ISampleInfo)}s."); + + this.sampleInfo = sampleInfo; + + Volume.Value = sampleInfo.Volume / 100.0; + + if (LoadState >= LoadState.Ready) + updateSample(); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + updateSample(); + } + + private void updateSample() + { + if (sampleInfo == null) + return; + + bool wasPlaying = Playing; + + sampleContainer.Clear(); + Sample = null; + + var ch = CurrentSkin.GetSample(sampleInfo); + + if (ch == null && AllowDefaultFallback) + { + foreach (var lookup in sampleInfo.LookupNames) + { + if ((ch = sampleStore.Get(lookup)) != null) + break; + } + } + + if (ch == null) + return; + + sampleContainer.Add(Sample = new DrawableSample(ch) { Looping = Looping }); + + // Start playback internally for the new sample if the previous one was playing beforehand. + if (wasPlaying) + Play(); + } + + /// + /// Plays the sample. + /// + /// Whether to play the sample from the beginning. + public void Play(bool restart = true) => Sample?.Play(restart); + + /// + /// Stops the sample. + /// + public void Stop() => Sample?.Stop(); + + /// + /// Whether the sample is currently playing. + /// + public bool Playing => Sample?.Playing ?? false; + + private bool looping; + + /// + /// Whether the sample should loop on completion. + /// + public bool Looping + { + get => looping; + set + { + looping = value; + + if (Sample != null) + Sample.Looping = value; + } + } + + #region Re-expose AudioContainer + + public BindableNumber Volume => sampleContainer.Volume; + + public BindableNumber Balance => sampleContainer.Balance; + + public BindableNumber Frequency => sampleContainer.Frequency; + + public BindableNumber Tempo => sampleContainer.Tempo; + + public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => sampleContainer.AddAdjustment(type, adjustBindable); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => sampleContainer.RemoveAdjustment(type, adjustBindable); + + public void RemoveAllAdjustments(AdjustableProperty type) => sampleContainer.RemoveAllAdjustments(type); + + public IBindable AggregateVolume => sampleContainer.AggregateVolume; + + public IBindable AggregateBalance => sampleContainer.AggregateBalance; + + public IBindable AggregateFrequency => sampleContainer.AggregateFrequency; + + public IBindable AggregateTempo => sampleContainer.AggregateTempo; + + #endregion + } +} diff --git a/osu.Game/Skinning/SkinReloadableDrawable.cs b/osu.Game/Skinning/SkinReloadableDrawable.cs index cc9cbf7b59..50b4143375 100644 --- a/osu.Game/Skinning/SkinReloadableDrawable.cs +++ b/osu.Game/Skinning/SkinReloadableDrawable.cs @@ -27,7 +27,7 @@ namespace osu.Game.Skinning /// /// Whether fallback to default skin should be allowed if the custom skin is missing this resource. /// - private bool allowDefaultFallback => allowFallback == null || allowFallback.Invoke(CurrentSkin); + protected bool AllowDefaultFallback => allowFallback == null || allowFallback.Invoke(CurrentSkin); /// /// Create a new @@ -58,7 +58,7 @@ namespace osu.Game.Skinning private void skinChanged() { - SkinChanged(CurrentSkin, allowDefaultFallback); + SkinChanged(CurrentSkin, AllowDefaultFallback); OnSkinChanged?.Invoke(); } diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index ffa0a963ce..23159e4fe1 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -1,26 +1,27 @@ // 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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; using osu.Game.Audio; namespace osu.Game.Skinning { + /// + /// A sound consisting of one or more samples to be played. + /// public class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent { - private readonly ISampleInfo[] hitSamples; - - [Resolved] - private ISampleStore samples { get; set; } - public override bool RemoveWhenNotAlive => false; public override bool RemoveCompletedTransforms => false; @@ -34,21 +35,74 @@ namespace osu.Game.Skinning /// protected bool PlayWhenZeroVolume => Looping; - protected readonly AudioContainer SamplesContainer; + /// + /// All raw s contained in this . + /// + [NotNull, ItemNotNull] + protected IEnumerable DrawableSamples => samplesContainer.Select(c => c.Sample).Where(s => s != null); - public SkinnableSound(ISampleInfo hitSamples) - : this(new[] { hitSamples }) + private readonly AudioContainer samplesContainer; + + [Resolved] + private ISampleStore sampleStore { get; set; } + + [Resolved(CanBeNull = true)] + private IPooledSampleProvider samplePool { get; set; } + + /// + /// Creates a new . + /// + public SkinnableSound() + { + InternalChild = samplesContainer = new AudioContainer(); + } + + /// + /// Creates a new with some initial samples. + /// + /// The initial samples. + public SkinnableSound([NotNull] IEnumerable samples) + : this() + { + this.samples = samples.ToArray(); + } + + /// + /// Creates a new with an initial sample. + /// + /// The initial sample. + public SkinnableSound([NotNull] ISampleInfo sample) + : this(new[] { sample }) { } - public SkinnableSound(IEnumerable hitSamples) + private ISampleInfo[] samples = Array.Empty(); + + /// + /// The samples that should be played. + /// + public ISampleInfo[] Samples { - this.hitSamples = hitSamples.ToArray(); - InternalChild = SamplesContainer = new AudioContainer(); + get => samples; + set + { + value ??= Array.Empty(); + + if (samples == value) + return; + + samples = value; + + if (LoadState >= LoadState.Ready) + updateSamples(); + } } private bool looping; + /// + /// Whether the samples should loop on completion. + /// public bool Looping { get => looping; @@ -58,77 +112,80 @@ namespace osu.Game.Skinning looping = value; - SamplesContainer.ForEach(c => c.Looping = looping); + samplesContainer.ForEach(c => c.Looping = looping); } } + /// + /// Plays the samples. + /// public virtual void Play() { - SamplesContainer.ForEach(c => + samplesContainer.ForEach(c => { if (PlayWhenZeroVolume || c.AggregateVolume.Value > 0) c.Play(); }); } + /// + /// Stops the samples. + /// public virtual void Stop() { - SamplesContainer.ForEach(c => c.Stop()); + samplesContainer.ForEach(c => c.Stop()); } protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + updateSamples(); + } + + private void updateSamples() { bool wasPlaying = IsPlaying; - var channels = hitSamples.Select(s => + // Remove all pooled samples (return them to the pool), and dispose the rest. + samplesContainer.RemoveAll(s => s.IsInPool); + samplesContainer.Clear(); + + foreach (var s in samples) { - var ch = skin.GetSample(s); + var sample = samplePool?.GetPooledSample(s) ?? new PoolableSkinnableSample(s); + sample.Looping = Looping; + sample.Volume.Value = s.Volume / 100.0; - if (ch == null && allowFallback) - { - foreach (var lookup in s.LookupNames) - { - if ((ch = samples.Get(lookup)) != null) - break; - } - } + samplesContainer.Add(sample); + } - if (ch != null) - { - ch.Looping = looping; - ch.Volume.Value = s.Volume / 100.0; - } - - return ch; - }).Where(c => c != null); - - SamplesContainer.ChildrenEnumerable = channels.Select(c => new DrawableSample(c)); - - // Start playback internally for the new samples if the previous ones were playing beforehand. if (wasPlaying) Play(); } #region Re-expose AudioContainer - public BindableNumber Volume => SamplesContainer.Volume; + public BindableNumber Volume => samplesContainer.Volume; - public BindableNumber Balance => SamplesContainer.Balance; + public BindableNumber Balance => samplesContainer.Balance; - public BindableNumber Frequency => SamplesContainer.Frequency; + public BindableNumber Frequency => samplesContainer.Frequency; - public BindableNumber Tempo => SamplesContainer.Tempo; + public BindableNumber Tempo => samplesContainer.Tempo; public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) - => SamplesContainer.AddAdjustment(type, adjustBindable); + => samplesContainer.AddAdjustment(type, adjustBindable); public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) - => SamplesContainer.RemoveAdjustment(type, adjustBindable); + => samplesContainer.RemoveAdjustment(type, adjustBindable); public void RemoveAllAdjustments(AdjustableProperty type) - => SamplesContainer.RemoveAllAdjustments(type); + => samplesContainer.RemoveAllAdjustments(type); - public bool IsPlaying => SamplesContainer.Any(s => s.Playing); + /// + /// Whether any samples are currently playing. + /// + public bool IsPlaying => samplesContainer.Any(s => s.Playing); #endregion } diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 5352928ec6..1340d1474c 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -24,7 +24,15 @@ namespace osu.Game.Skinning { } - protected override Drawable CreateDefault(ISkinComponent component) => new Sprite { Texture = textures.Get(component.LookupName) }; + protected override Drawable CreateDefault(ISkinComponent component) + { + var texture = textures.Get(component.LookupName); + + if (texture == null) + return null; + + return new Sprite { Texture = texture }; + } private class SpriteComponent : ISkinComponent { diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index 08811b9b8c..218f051bf0 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -37,7 +37,7 @@ namespace osu.Game.Storyboards.Drawables foreach (var mod in mods.Value.OfType()) { - foreach (var sample in SamplesContainer) + foreach (var sample in DrawableSamples) mod.ApplyToSample(sample); } } diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 87b77f4616..035cb64099 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -31,6 +31,7 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo.BeatmapSet.Metadata = BeatmapInfo.Metadata; BeatmapInfo.BeatmapSet.Files = new List(); BeatmapInfo.BeatmapSet.Beatmaps = new List { BeatmapInfo }; + BeatmapInfo.Length = 75000; BeatmapInfo.BeatmapSet.OnlineInfo = new BeatmapSetOnlineInfo { Status = BeatmapSetOnlineStatus.Ranked, diff --git a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs new file mode 100644 index 0000000000..054f72400e --- /dev/null +++ b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.IO.Stores; +using osu.Game.Rulesets; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual +{ + [TestFixture] + public abstract class LegacySkinPlayerTestScene : PlayerTestScene + { + private ISkinSource legacySkinSource; + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new SkinProvidingPlayer(legacySkinSource); + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuGameBase game) + { + var legacySkin = new DefaultLegacySkin(new NamespacedResourceStore(game.Resources, "Skins/Legacy"), audio); + legacySkinSource = new SkinProvidingContainer(legacySkin); + } + + public class SkinProvidingPlayer : TestPlayer + { + [Cached(typeof(ISkinSource))] + private readonly ISkinSource skinSource; + + public SkinProvidingPlayer(ISkinSource skinSource) + { + this.skinSource = skinSource; + } + } + } +} diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index c3d74f21aa..78a6bcc3db 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -72,7 +72,7 @@ namespace osu.Game.Tests.Visual { base.Update(); - currentBlueprint.UpdatePosition(SnapForBlueprint(currentBlueprint)); + currentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(currentBlueprint)); } protected virtual SnapResult SnapForBlueprint(PlacementBlueprint blueprint) => @@ -85,7 +85,7 @@ namespace osu.Game.Tests.Visual if (drawable is PlacementBlueprint blueprint) { blueprint.Show(); - blueprint.UpdatePosition(SnapForBlueprint(blueprint)); + blueprint.UpdateTimeAndPosition(SnapForBlueprint(blueprint)); } } diff --git a/osu.Game/Utils/Optional.cs b/osu.Game/Utils/Optional.cs new file mode 100644 index 0000000000..9f8a1c2e62 --- /dev/null +++ b/osu.Game/Utils/Optional.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +namespace osu.Game.Utils +{ + /// + /// A wrapper over a value and a boolean denoting whether the value is valid. + /// + /// The type of value stored. + public readonly ref struct Optional + { + /// + /// The stored value. + /// + public readonly T Value; + + /// + /// Whether is valid. + /// + /// + /// If is a reference type, null may be valid for . + /// + public readonly bool HasValue; + + private Optional(T value) + { + Value = value; + HasValue = true; + } + + /// + /// Returns if it's valid, or a given fallback value otherwise. + /// + /// + /// Shortcase for: optional.HasValue ? optional.Value : fallback. + /// + /// The fallback value to return if is false. + /// + public T GetOr(T fallback) => HasValue ? Value : fallback; + + public static implicit operator Optional(T value) => new Optional(value); + } +} diff --git a/osu.Game/Utils/StatelessRNG.cs b/osu.Game/Utils/StatelessRNG.cs new file mode 100644 index 0000000000..118b08fe30 --- /dev/null +++ b/osu.Game/Utils/StatelessRNG.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Utils +{ + /// + /// Provides a fast stateless function that can be used in randomly-looking visual elements. + /// + public static class StatelessRNG + { + private static ulong mix(ulong x) + { + unchecked + { + x ^= x >> 33; + x *= 0xff51afd7ed558ccd; + x ^= x >> 33; + x *= 0xc4ceb9fe1a85ec53; + x ^= x >> 33; + return x; + } + } + + /// + /// Generate a random 64-bit unsigned integer from given seed. + /// + /// + /// The seed value of this random number generator. + /// + /// + /// The series number. + /// Different values are computed for the same seed in different series. + /// + public static ulong NextULong(int seed, int series = 0) + { + unchecked + { + var combined = ((ulong)(uint)series << 32) | (uint)seed; + // The xor operation is to not map (0, 0) to 0. + return mix(combined ^ 0x12345678); + } + } + + /// + /// Generate a random integer in range [0, maxValue) from given seed. + /// + /// + /// The number of possible results. + /// + /// + /// The seed value of this random number generator. + /// + /// + /// The series number. + /// Different values are computed for the same seed in different series. + /// + public static int NextInt(int maxValue, int seed, int series = 0) + { + if (maxValue <= 0) throw new ArgumentOutOfRangeException(nameof(maxValue)); + + return (int)(NextULong(seed, series) % (ulong)maxValue); + } + + /// + /// Compute a random floating point value between 0 and 1 (excluding 1) from given seed and series number. + /// + /// + /// The seed value of this random number generator. + /// + /// + /// The series number. + /// Different values are computed for the same seed in different series. + /// + public static float NextSingle(int seed, int series = 0) => + (float)(NextULong(seed, series) & ((1 << 24) - 1)) / (1 << 24); // float has 24-bit precision + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 1850ee3488..9d37ceee6c 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,7 +18,7 @@ - + @@ -26,11 +26,11 @@ - - - + + + - + diff --git a/osu.iOS.props b/osu.iOS.props index 2ac23f1503..ab03393836 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -88,11 +88,11 @@ - + - +