diff --git a/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs index 07c7b4d1db..a1d000386f 100644 --- a/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs @@ -25,16 +25,22 @@ namespace osu.Game.Rulesets.Taiko.Tests private ScrollingHitObjectContainer hitObjectContainer; - [SetUpSteps] - public void SetUp() - => AddStep("create SHOC", () => Child = hitObjectContainer = new ScrollingHitObjectContainer + [BackgroundDependencyLoader] + private void load() + { + Child = hitObjectContainer = new ScrollingHitObjectContainer { RelativeSizeAxes = Axes.X, Height = 200, Anchor = Anchor.Centre, Origin = Anchor.Centre, Clock = new FramedClock(new StopwatchClock()) - }); + }; + } + + [SetUpSteps] + public void SetUp() + => AddStep("clear SHOC", () => hitObjectContainer.Clear(false)); protected void AddHitObject(DrawableHitObject hitObject) => AddStep("add to SHOC", () => hitObjectContainer.Add(hitObject)); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs index 65230a07bc..a970965141 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs @@ -12,12 +12,13 @@ namespace osu.Game.Rulesets.Taiko.Tests [Test] public void TestApplyNewBarLine() { - DrawableBarLine barLine = new DrawableBarLine(PrepareObject(new BarLine + DrawableBarLine barLine = new DrawableBarLine(); + + AddStep("apply new bar line", () => barLine.Apply(PrepareObject(new BarLine { StartTime = 400, Major = true - })); - + }), null)); AddHitObject(barLine); RemoveHitObject(barLine); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs new file mode 100644 index 0000000000..54450e27db --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.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.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneDrumRollApplication : HitObjectApplicationTestScene + { + [Test] + public void TestApplyNewDrumRoll() + { + var drumRoll = new DrawableDrumRoll(); + + AddStep("apply new drum roll", () => drumRoll.Apply(PrepareObject(new DrumRoll + { + StartTime = 300, + Duration = 500, + IsStrong = false, + TickRate = 2 + }), null)); + + AddHitObject(drumRoll); + RemoveHitObject(drumRoll); + + AddStep("apply new drum roll", () => drumRoll.Apply(PrepareObject(new DrumRoll + { + StartTime = 150, + Duration = 400, + IsStrong = true, + TickRate = 16 + }), null)); + + AddHitObject(drumRoll); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs new file mode 100644 index 0000000000..52fd440857 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.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 NUnit.Framework; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneHitApplication : HitObjectApplicationTestScene + { + [Test] + public void TestApplyNewHit() + { + var hit = new DrawableHit(); + + AddStep("apply new hit", () => hit.Apply(PrepareObject(new Hit + { + Type = HitType.Rim, + IsStrong = false, + StartTime = 300 + }), null)); + + AddHitObject(hit); + RemoveHitObject(hit); + + AddStep("apply new hit", () => hit.Apply(PrepareObject(new Hit + { + Type = HitType.Centre, + IsStrong = true, + StartTime = 500 + }), null)); + + AddHitObject(hit); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs index 4ba9c447fb..296468d98d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.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.Collections.Generic; using System.Linq; using osu.Framework.Testing; using osu.Game.Audio; @@ -18,24 +19,33 @@ namespace osu.Game.Rulesets.Taiko.Tests public override void SetUpSteps() { base.SetUpSteps(); - AddAssert("has correct samples", () => + + var expectedSampleNames = new[] { - var names = Player.DrawableRuleset.Playfield.AllHitObjects.OfType().Select(h => string.Join(',', h.GetSamples().Select(s => s.Name))); + string.Empty, + string.Empty, + string.Empty, + string.Empty, + HitSampleInfo.HIT_FINISH, + HitSampleInfo.HIT_WHISTLE, + HitSampleInfo.HIT_WHISTLE, + HitSampleInfo.HIT_WHISTLE, + }; + var actualSampleNames = new List(); - var expected = new[] - { - string.Empty, - string.Empty, - string.Empty, - string.Empty, - HitSampleInfo.HIT_FINISH, - HitSampleInfo.HIT_WHISTLE, - HitSampleInfo.HIT_WHISTLE, - HitSampleInfo.HIT_WHISTLE, - }; + // due to pooling we can't access all samples right away due to object re-use, + // so we need to collect as we go. + AddStep("collect sample names", () => Player.DrawableRuleset.Playfield.NewResult += (dho, _) => + { + if (!(dho is DrawableHit h)) + return; - return names.SequenceEqual(expected); + actualSampleNames.Add(string.Join(',', h.GetSamples().Select(s => s.Name))); }); + + AddUntilStep("all samples collected", () => actualSampleNames.Count == expectedSampleNames.Length); + + AddAssert("samples are correct", () => actualSampleNames.SequenceEqual(expectedSampleNames)); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions"); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 4925b6fdfc..d066abf767 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Utils; using osu.Game.Graphics; @@ -31,15 +32,26 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private int rollingHits; - private Container tickContainer; + private readonly Container tickContainer; private Color4 colourIdle; private Color4 colourEngaged; - public DrawableDrumRoll(DrumRoll drumRoll) + public DrawableDrumRoll() + : this(null) + { + } + + public DrawableDrumRoll([CanBeNull] DrumRoll drumRoll) : base(drumRoll) { RelativeSizeAxes = Axes.Y; + + Content.Add(tickContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Depth = float.MinValue + }); } [BackgroundDependencyLoader] @@ -47,12 +59,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { colourIdle = colours.YellowDark; colourEngaged = colours.YellowDarker; - - Content.Add(tickContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Depth = float.MinValue - }); } protected override void LoadComplete() @@ -68,6 +74,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables updateColour(); } + protected override void OnFree() + { + base.OnFree(); + rollingHits = 0; + } + protected override void AddNestedHitObject(DrawableHitObject hitObject) { base.AddNestedHitObject(hitObject); @@ -83,7 +95,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void ClearNestedHitObjects() { base.ClearNestedHitObjects(); - tickContainer.Clear(); + tickContainer.Clear(false); } protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) @@ -114,7 +126,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables rollingHits = Math.Clamp(rollingHits, 0, rolling_hits_for_engaged_colour); - updateColour(); + updateColour(100); } protected override void CheckForResult(bool userTriggered, double timeOffset) @@ -154,27 +166,34 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Content.X = DrawHeight / 2; } - protected override DrawableStrongNestedHit CreateStrongNestedHit(DrumRoll.StrongNestedHit hitObject) => new StrongNestedHit(hitObject, this); + protected override DrawableStrongNestedHit CreateStrongNestedHit(DrumRoll.StrongNestedHit hitObject) => new StrongNestedHit(hitObject); - private void updateColour() + private void updateColour(double fadeDuration = 0) { Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); - (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, 100); + (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); } - private class StrongNestedHit : DrawableStrongNestedHit + public class StrongNestedHit : DrawableStrongNestedHit { - public StrongNestedHit(DrumRoll.StrongNestedHit nestedHit, DrawableDrumRoll drumRoll) - : base(nestedHit, drumRoll) + public new DrawableDrumRoll ParentHitObject => (DrawableDrumRoll)base.ParentHitObject; + + public StrongNestedHit() + : this(null) + { + } + + public StrongNestedHit([CanBeNull] DrumRoll.StrongNestedHit nestedHit) + : base(nestedHit) { } protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (!MainObject.Judged) + if (!ParentHitObject.Judged) return; - ApplyResult(r => r.Type = MainObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); + ApplyResult(r => r.Type = ParentHitObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); } public override bool OnPressed(TaikoAction action) => false; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index c6761de5e3..0df45c424d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Skinning.Default; @@ -16,7 +17,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// public HitType JudgementType; - public DrawableDrumRollTick(DrumRollTick tick) + public DrawableDrumRollTick() + : this(null) + { + } + + public DrawableDrumRollTick([CanBeNull] DrumRollTick tick) : base(tick) { FillMode = FillMode.Fit; @@ -61,21 +67,28 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return UpdateResult(true); } - protected override DrawableStrongNestedHit CreateStrongNestedHit(DrumRollTick.StrongNestedHit hitObject) => new StrongNestedHit(hitObject, this); + protected override DrawableStrongNestedHit CreateStrongNestedHit(DrumRollTick.StrongNestedHit hitObject) => new StrongNestedHit(hitObject); - private class StrongNestedHit : DrawableStrongNestedHit + public class StrongNestedHit : DrawableStrongNestedHit { - public StrongNestedHit(DrumRollTick.StrongNestedHit nestedHit, DrawableDrumRollTick tick) - : base(nestedHit, tick) + public new DrawableDrumRollTick ParentHitObject => (DrawableDrumRollTick)base.ParentHitObject; + + public StrongNestedHit() + : this(null) + { + } + + public StrongNestedHit([CanBeNull] DrumRollTick.StrongNestedHit nestedHit) + : base(nestedHit) { } protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (!MainObject.Judged) + if (!ParentHitObject.Judged) return; - ApplyResult(r => r.Type = MainObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); + ApplyResult(r => r.Type = ParentHitObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); } public override bool OnPressed(TaikoAction action) => false; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 431f2980ec..38cda69a46 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using osu.Framework.Allocation; +using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Audio; @@ -36,29 +36,51 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private bool pressHandledThisFrame; - private readonly Bindable type; + private readonly Bindable type = new Bindable(); - public DrawableHit(Hit hit) - : base(hit) + public DrawableHit() + : this(null) { - type = HitObject.TypeBindable.GetBoundCopy(); - FillMode = FillMode.Fit; - - updateActionsFromType(); } - [BackgroundDependencyLoader] - private void load() + public DrawableHit([CanBeNull] Hit hit) + : base(hit) { + FillMode = FillMode.Fit; + } + + protected override void OnApply() + { + type.BindTo(HitObject.TypeBindable); type.BindValueChanged(_ => { updateActionsFromType(); - // will overwrite samples, should only be called on change. + // will overwrite samples, should only be called on subsequent changes + // after the initial application. updateSamplesFromTypeChange(); RecreatePieces(); }); + + // action update also has to happen immediately on application. + updateActionsFromType(); + + base.OnApply(); + } + + protected override void OnFree() + { + base.OnFree(); + + type.UnbindFrom(HitObject.TypeBindable); + type.UnbindEvents(); + + UnproxyContent(); + + HitActions = null; + HitAction = null; + validActionPressed = pressHandledThisFrame = false; } private HitSampleInfo[] getRimSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE).ToArray(); @@ -228,32 +250,37 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - protected override DrawableStrongNestedHit CreateStrongNestedHit(Hit.StrongNestedHit hitObject) => new StrongNestedHit(hitObject, this); + protected override DrawableStrongNestedHit CreateStrongNestedHit(Hit.StrongNestedHit hitObject) => new StrongNestedHit(hitObject); - private class StrongNestedHit : DrawableStrongNestedHit + public class StrongNestedHit : DrawableStrongNestedHit { + public new DrawableHit ParentHitObject => (DrawableHit)base.ParentHitObject; + /// /// The lenience for the second key press. /// This does not adjust by map difficulty in ScoreV2 yet. /// private const double second_hit_window = 30; - public new DrawableHit MainObject => (DrawableHit)base.MainObject; + public StrongNestedHit() + : this(null) + { + } - public StrongNestedHit(Hit.StrongNestedHit nestedHit, DrawableHit hit) - : base(nestedHit, hit) + public StrongNestedHit([CanBeNull] Hit.StrongNestedHit nestedHit) + : base(nestedHit) { } protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (!MainObject.Result.HasResult) + if (!ParentHitObject.Result.HasResult) { base.CheckForResult(userTriggered, timeOffset); return; } - if (!MainObject.Result.IsHit) + if (!ParentHitObject.Result.IsHit) { ApplyResult(r => r.Type = r.Judgement.MinResult); return; @@ -261,27 +288,27 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (!userTriggered) { - if (timeOffset - MainObject.Result.TimeOffset > second_hit_window) + if (timeOffset - ParentHitObject.Result.TimeOffset > second_hit_window) ApplyResult(r => r.Type = r.Judgement.MinResult); return; } - if (Math.Abs(timeOffset - MainObject.Result.TimeOffset) <= second_hit_window) + if (Math.Abs(timeOffset - ParentHitObject.Result.TimeOffset) <= second_hit_window) ApplyResult(r => r.Type = r.Judgement.MaxResult); } public override bool OnPressed(TaikoAction action) { // Don't process actions until the main hitobject is hit - if (!MainObject.IsHit) + if (!ParentHitObject.IsHit) return false; // Don't process actions if the pressed button was released - if (MainObject.HitAction == null) + if (ParentHitObject.HitAction == null) return false; // Don't handle invalid hit action presses - if (!MainObject.HitActions.Contains(action)) + if (!ParentHitObject.HitActions.Contains(action)) return false; return UpdateResult(true); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs index d2e8888197..9c22e34387 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs @@ -1,7 +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 osu.Game.Rulesets.Objects.Drawables; +using JetBrains.Annotations; using osu.Game.Rulesets.Taiko.Judgements; namespace osu.Game.Rulesets.Taiko.Objects.Drawables @@ -11,12 +11,11 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// public abstract class DrawableStrongNestedHit : DrawableTaikoHitObject { - public readonly DrawableHitObject MainObject; + public new DrawableTaikoHitObject ParentHitObject => (DrawableTaikoHitObject)base.ParentHitObject; - protected DrawableStrongNestedHit(StrongNestedHitObject nestedHit, DrawableHitObject mainObject) + protected DrawableStrongNestedHit([CanBeNull] StrongNestedHitObject nestedHit) : base(nestedHit) { - MainObject = mainObject; } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 229d581d0c..60f9521996 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -35,7 +36,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private readonly CircularContainer targetRing; private readonly CircularContainer expandingRing; - public DrawableSwell(Swell swell) + public DrawableSwell() + : this(null) + { + } + + public DrawableSwell([CanBeNull] Swell swell) : base(swell) { FillMode = FillMode.Fit; @@ -123,12 +129,13 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Origin = Anchor.Centre, }); - protected override void LoadComplete() + protected override void OnFree() { - base.LoadComplete(); + base.OnFree(); - // We need to set this here because RelativeSizeAxes won't/can't set our size by default with a different RelativeChildSize - Width *= Parent.RelativeChildSize.X; + UnproxyContent(); + + lastWasCentre = null; } protected override void AddNestedHitObject(DrawableHitObject hitObject) @@ -146,7 +153,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void ClearNestedHitObjects() { base.ClearNestedHitObjects(); - ticks.Clear(); + ticks.Clear(false); } protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs index 14c86d151f..47fc7e5ab3 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; @@ -11,7 +12,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public override bool DisplayResult => false; - public DrawableSwellTick(SwellTick hitObject) + public DrawableSwellTick() + : this(null) + { + } + + public DrawableSwellTick([CanBeNull] SwellTick hitObject) : base(hitObject) { } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index cf3aa69b6f..6041eccb51 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; +using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private readonly Container nonProxiedContent; - protected DrawableTaikoHitObject(TaikoHitObject hitObject) + protected DrawableTaikoHitObject([CanBeNull] TaikoHitObject hitObject) : base(hitObject) { AddRangeInternal(new[] @@ -113,25 +113,23 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public override Vector2 OriginPosition => new Vector2(DrawHeight / 2); - public new TObject HitObject; + public new TObject HitObject => (TObject)base.HitObject; protected Vector2 BaseSize; protected SkinnableDrawable MainPiece; - protected DrawableTaikoHitObject(TObject hitObject) + protected DrawableTaikoHitObject([CanBeNull] TObject hitObject) : base(hitObject) { - HitObject = hitObject; - Anchor = Anchor.CentreLeft; Origin = Anchor.Custom; RelativeSizeAxes = Axes.Both; } - [BackgroundDependencyLoader] - private void load() + protected override void OnApply() { + base.OnApply(); RecreatePieces(); } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs index af3e94d9c6..4f1523eb3f 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; +using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Audio; @@ -16,28 +16,38 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables where TObject : TaikoStrongableHitObject where TStrongNestedObject : StrongNestedHitObject { - private readonly Bindable isStrong; + private readonly Bindable isStrong = new BindableBool(); private readonly Container strongHitContainer; - protected DrawableTaikoStrongableHitObject(TObject hitObject) + protected DrawableTaikoStrongableHitObject([CanBeNull] TObject hitObject) : base(hitObject) { - isStrong = HitObject.IsStrongBindable.GetBoundCopy(); - AddInternal(strongHitContainer = new Container()); } - [BackgroundDependencyLoader] - private void load() + protected override void OnApply() { + isStrong.BindTo(HitObject.IsStrongBindable); isStrong.BindValueChanged(_ => { - // will overwrite samples, should only be called on change. + // will overwrite samples, should only be called on subsequent changes + // after the initial application. updateSamplesFromStrong(); RecreatePieces(); }); + + base.OnApply(); + } + + protected override void OnFree() + { + base.OnFree(); + + isStrong.UnbindFrom(HitObject.IsStrongBindable); + // ensure the next application does not accidentally overwrite samples. + isStrong.UnbindEvents(); } private HitSampleInfo[] getStrongSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray(); @@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void ClearNestedHitObjects() { base.ClearNestedHitObjects(); - strongHitContainer.Clear(); + strongHitContainer.Clear(false); } protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 9cf931ee0a..ed8e6859a2 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.Taiko.Replays; using osu.Framework.Input; @@ -64,22 +63,7 @@ namespace osu.Game.Rulesets.Taiko.UI protected override Playfield CreatePlayfield() => new TaikoPlayfield(Beatmap.ControlPointInfo); - public override DrawableHitObject CreateDrawableRepresentation(TaikoHitObject h) - { - switch (h) - { - case Hit hit: - return new DrawableHit(hit); - - case DrumRoll drumRoll: - return new DrawableDrumRoll(drumRoll); - - case Swell swell: - return new DrawableSwell(swell); - } - - return null; - } + public override DrawableHitObject CreateDrawableRepresentation(TaikoHitObject h) => null; protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new TaikoFramedReplayInputHandler(replay); diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index d20b190c86..148ec7755e 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -147,6 +147,32 @@ namespace osu.Game.Rulesets.Taiko.UI }, drumRollHitContainer.CreateProxy(), }; + + RegisterPool(50); + RegisterPool(50); + + RegisterPool(5); + RegisterPool(5); + + RegisterPool(100); + RegisterPool(100); + + RegisterPool(5); + RegisterPool(100); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + NewResult += OnNewResult; + } + + protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject) + { + base.OnNewDrawableHitObject(drawableHitObject); + + var taikoObject = (DrawableTaikoHitObject)drawableHitObject; + topLevelHitContainer.Add(taikoObject.CreateProxiedContent()); } protected override void Update() @@ -207,9 +233,7 @@ namespace osu.Game.Rulesets.Taiko.UI barLinePlayfield.Add(barLine); break; - case DrawableTaikoHitObject taikoObject: - h.OnNewResult += OnNewResult; - topLevelHitContainer.Add(taikoObject.CreateProxiedContent()); + case DrawableTaikoHitObject _: base.Add(h); break; @@ -226,8 +250,6 @@ namespace osu.Game.Rulesets.Taiko.UI return barLinePlayfield.Remove(barLine); case DrawableTaikoHitObject _: - h.OnNewResult -= OnNewResult; - // todo: consider tidying of proxied content if required. return base.Remove(h); default: @@ -248,7 +270,7 @@ namespace osu.Game.Rulesets.Taiko.UI { case TaikoStrongJudgement _: if (result.IsHit) - hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).MainObject)?.VisualiseSecondHit(); + hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(); break; case TaikoDrumRollTickJudgement _: diff --git a/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs b/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs index fb10015ef4..2236f85b92 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs @@ -61,12 +61,12 @@ namespace osu.Game.Tests.Visual.Components { createPoller(true); - AddStep("set poll interval to 1", () => poller.TimeBetweenPolls = TimePerAction * safety_adjust); + AddStep("set poll interval to 1", () => poller.TimeBetweenPolls.Value = TimePerAction * safety_adjust); checkCount(1); checkCount(2); checkCount(3); - AddStep("set poll interval to 5", () => poller.TimeBetweenPolls = TimePerAction * safety_adjust * 5); + AddStep("set poll interval to 5", () => poller.TimeBetweenPolls.Value = TimePerAction * safety_adjust * 5); checkCount(4); checkCount(4); checkCount(4); @@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual.Components checkCount(5); checkCount(5); - AddStep("set poll interval to 1", () => poller.TimeBetweenPolls = TimePerAction * safety_adjust); + AddStep("set poll interval to 1", () => poller.TimeBetweenPolls.Value = TimePerAction * safety_adjust); checkCount(6); checkCount(7); } @@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.Components { createPoller(false); - AddStep("set poll interval to 1", () => poller.TimeBetweenPolls = TimePerAction * safety_adjust * 5); + AddStep("set poll interval to 1", () => poller.TimeBetweenPolls.Value = TimePerAction * safety_adjust * 5); checkCount(0); skip(); checkCount(0); @@ -141,7 +141,7 @@ namespace osu.Game.Tests.Visual.Components public class TestSlowPoller : TestPoller { - protected override Task Poll() => Task.Delay((int)(TimeBetweenPolls / 2f / Clock.Rate)).ContinueWith(_ => base.Poll()); + protected override Task Poll() => Task.Delay((int)(TimeBetweenPolls.Value / 2f / Clock.Rate)).ContinueWith(_ => base.Poll()); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs index 6fd5511e5a..4ee48fd853 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs @@ -10,6 +10,8 @@ using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; using osu.Game.Storyboards; using osuTK; @@ -50,7 +52,7 @@ namespace osu.Game.Tests.Visual.Gameplay cancel(); complete(); - AddUntilStep("attempted to push ranking", () => ((FakeRankingPushPlayer)Player).GotoRankingInvoked); + AddUntilStep("attempted to push ranking", () => ((FakeRankingPushPlayer)Player).ResultsCreated); } /// @@ -84,7 +86,7 @@ namespace osu.Game.Tests.Visual.Gameplay { // wait to ensure there was no attempt of pushing the results screen. AddWaitStep("wait", resultsDisplayWaitCount); - AddAssert("no attempt to push ranking", () => !((FakeRankingPushPlayer)Player).GotoRankingInvoked); + AddAssert("no attempt to push ranking", () => !((FakeRankingPushPlayer)Player).ResultsCreated); } protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) @@ -110,16 +112,18 @@ namespace osu.Game.Tests.Visual.Gameplay public class FakeRankingPushPlayer : TestPlayer { - public bool GotoRankingInvoked; + public bool ResultsCreated { get; private set; } public FakeRankingPushPlayer() : base(true, true) { } - protected override void GotoRanking() + protected override ResultsScreen CreateResults(ScoreInfo score) { - GotoRankingInvoked = true; + var results = base.CreateResults(score); + ResultsCreated = true; + return results; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs index 67a53307fc..9dd4aea4bd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public readonly BindableList Rooms = new BindableList(); - public Bindable InitialRoomsReceived { get; } = new Bindable(true); + public IBindable InitialRoomsReceived { get; } = new Bindable(true); IBindableList IRoomManager.Rooms => Rooms; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs index cbe8cc6137..234374ee2b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs @@ -131,7 +131,7 @@ namespace osu.Game.Tests.Visual.Multiplayer remove { } } - public Bindable InitialRoomsReceived { get; } = new Bindable(true); + public IBindable InitialRoomsReceived { get; } = new Bindable(true); public IBindableList Rooms => null; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs index 65e9893851..bceb6efac1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs @@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.Multiplayer remove => throw new NotImplementedException(); } - public Bindable InitialRoomsReceived { get; } = new Bindable(true); + public IBindable InitialRoomsReceived { get; } = new Bindable(true); public IBindableList Rooms { get; } = new BindableList(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs index 3924b0333f..0390b995e1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Game.Overlays; +using osu.Game.Screens.Multi.Timeshift; namespace osu.Game.Tests.Visual.Multiplayer { @@ -17,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public TestSceneMultiScreen() { - Screens.Multi.Multiplayer multi = new Screens.Multi.Multiplayer(); + var multi = new TimeshiftMultiplayer(); AddStep("show", () => LoadScreen(multi)); AddUntilStep("wait for loaded", () => multi.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs index c1dfb94464..a6dd1437f7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs @@ -22,22 +22,28 @@ namespace osu.Game.Tests.Visual.Multiplayer { new DrawableRoom(new Room { - Name = { Value = "Room 1" }, + Name = { Value = "Open - ending in 1 day" }, Status = { Value = new RoomStatusOpen() }, EndDate = { Value = DateTimeOffset.Now.AddDays(1) } }) { MatchingFilter = true }, new DrawableRoom(new Room { - Name = { Value = "Room 2" }, + Name = { Value = "Playing - ending in 1 day" }, Status = { Value = new RoomStatusPlaying() }, EndDate = { Value = DateTimeOffset.Now.AddDays(1) } }) { MatchingFilter = true }, new DrawableRoom(new Room { - Name = { Value = "Room 3" }, + Name = { Value = "Ended" }, Status = { Value = new RoomStatusEnded() }, EndDate = { Value = DateTimeOffset.Now } }) { MatchingFilter = true }, + new DrawableRoom(new Room + { + Name = { Value = "Open (realtime)" }, + Status = { Value = new RoomStatusOpen() }, + Category = { Value = RoomCategory.Realtime } + }) { MatchingFilter = true }, } }; } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index d87854a7ea..43f97d8ace 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -11,6 +11,7 @@ using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Toolbar; +using osu.Game.Screens.Multi.Timeshift; using osu.Game.Screens.Play; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Options; @@ -107,14 +108,14 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitMultiWithEscape() { - PushAndConfirm(() => new Screens.Multi.Multiplayer()); + PushAndConfirm(() => new TimeshiftMultiplayer()); exitViaEscapeAndConfirm(); } [Test] public void TestExitMultiWithBackButton() { - PushAndConfirm(() => new Screens.Multi.Multiplayer()); + PushAndConfirm(() => new TimeshiftMultiplayer()); exitViaBackButtonAndConfirm(); } diff --git a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneParticipantsList.cs b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneParticipantsList.cs new file mode 100644 index 0000000000..8c997e9e32 --- /dev/null +++ b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneParticipantsList.cs @@ -0,0 +1,116 @@ +// 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.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Screens.Multi.RealtimeMultiplayer.Participants; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + public class TestSceneParticipantsList : RealtimeMultiplayerTestScene + { + [SetUp] + public new void Setup() => Schedule(() => + { + Child = new ParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(380, 0.7f) + }; + }); + + [Test] + public void TestAddUser() + { + AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 1); + + AddStep("add user", () => Client.AddUser(new User + { + Id = 3, + Username = "Second", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + })); + + AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 2); + } + + [Test] + public void TestRemoveUser() + { + User secondUser = null; + + AddStep("add a user", () => + { + Client.AddUser(secondUser = new User + { + Id = 3, + Username = "Second", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + }); + + AddStep("remove host", () => Client.RemoveUser(API.LocalUser.Value)); + + AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().User.User == secondUser); + } + + [Test] + public void TestToggleReadyState() + { + AddAssert("ready mark invisible", () => !this.ChildrenOfType().Single().IsPresent); + + AddStep("make user ready", () => Client.ChangeState(MultiplayerUserState.Ready)); + AddUntilStep("ready mark visible", () => this.ChildrenOfType().Single().IsPresent); + + AddStep("make user idle", () => Client.ChangeState(MultiplayerUserState.Idle)); + AddUntilStep("ready mark invisible", () => !this.ChildrenOfType().Single().IsPresent); + } + + [Test] + public void TestCrownChangesStateWhenHostTransferred() + { + AddStep("add user", () => Client.AddUser(new User + { + Id = 3, + Username = "Second", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + })); + + AddUntilStep("first user crown visible", () => this.ChildrenOfType().ElementAt(0).ChildrenOfType().First().Alpha == 1); + AddUntilStep("second user crown hidden", () => this.ChildrenOfType().ElementAt(1).ChildrenOfType().First().Alpha == 0); + + AddStep("make second user host", () => Client.TransferHost(3)); + + AddUntilStep("first user crown hidden", () => this.ChildrenOfType().ElementAt(0).ChildrenOfType().First().Alpha == 0); + AddUntilStep("second user crown visible", () => this.ChildrenOfType().ElementAt(1).ChildrenOfType().First().Alpha == 1); + } + + [Test] + public void TestManyUsers() + { + AddStep("add many users", () => + { + for (int i = 0; i < 20; i++) + { + Client.AddUser(new User + { + Id = i, + Username = $"User {i}", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + + if (i % 2 == 0) + Client.ChangeUserState(i, MultiplayerUserState.Ready); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs new file mode 100644 index 0000000000..1f863028af --- /dev/null +++ b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Rulesets; +using osu.Game.Screens.Multi.RealtimeMultiplayer; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + public class TestSceneRealtimeReadyButton : RealtimeMultiplayerTestScene + { + private RealtimeReadyButton button; + + private BeatmapManager beatmaps; + private RulesetStore rulesets; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + } + + [SetUp] + public new void Setup() => Schedule(() => + { + var beatmap = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First().Beatmaps.First(); + + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); + + Child = button = new RealtimeReadyButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + SelectedItem = + { + Value = new PlaylistItem + { + Beatmap = { Value = beatmap }, + Ruleset = { Value = beatmap.Ruleset } + } + } + }; + + Client.AddUser(API.LocalUser.Value); + }); + + [Test] + public void TestToggleStateWhenNotHost() + { + AddStep("add second user as host", () => + { + Client.AddUser(new User { Id = 2, Username = "Another user" }); + Client.TransferHost(2); + }); + + addClickButtonStep(); + AddAssert("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); + + addClickButtonStep(); + AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + } + + [TestCase(true)] + [TestCase(false)] + public void TestToggleStateWhenHost(bool allReady) + { + AddStep("setup", () => + { + Client.TransferHost(Client.Room?.Users[0].UserID ?? 0); + + if (!allReady) + Client.AddUser(new User { Id = 2, Username = "Another user" }); + }); + + addClickButtonStep(); + AddAssert("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); + + addClickButtonStep(); + AddAssert("match started", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); + } + + [Test] + public void TestBecomeHostWhileReady() + { + AddStep("add host", () => + { + Client.AddUser(new User { Id = 2, Username = "Another user" }); + Client.TransferHost(2); + }); + + addClickButtonStep(); + AddStep("make user host", () => Client.TransferHost(Client.Room?.Users[0].UserID ?? 0)); + + addClickButtonStep(); + AddAssert("match started", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); + } + + [Test] + public void TestLoseHostWhileReady() + { + AddStep("setup", () => + { + Client.TransferHost(Client.Room?.Users[0].UserID ?? 0); + Client.AddUser(new User { Id = 2, Username = "Another user" }); + }); + + addClickButtonStep(); + AddStep("transfer host", () => Client.TransferHost(Client.Room?.Users[1].UserID ?? 0)); + + addClickButtonStep(); + AddAssert("match not started", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + } + + private void addClickButtonStep() => AddStep("click button", () => + { + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + } +} diff --git a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeRoomManager.cs b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeRoomManager.cs new file mode 100644 index 0000000000..925a83a863 --- /dev/null +++ b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeRoomManager.cs @@ -0,0 +1,153 @@ +// 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.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + [HeadlessTest] + public class TestSceneRealtimeRoomManager : MultiplayerTestScene + { + private TestRealtimeRoomContainer roomContainer; + private TestRealtimeRoomManager roomManager => roomContainer.RoomManager; + + [Test] + public void TestPollsInitially() + { + AddStep("create room manager with a few rooms", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + roomManager.CreateRoom(new Room { Name = { Value = "1" } }); + roomManager.PartRoom(); + roomManager.CreateRoom(new Room { Name = { Value = "2" } }); + roomManager.PartRoom(); + roomManager.ClearRooms(); + }); + }); + + AddAssert("manager polled for rooms", () => roomManager.Rooms.Count == 2); + AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); + } + + [Test] + public void TestRoomsClearedOnDisconnection() + { + AddStep("create room manager with a few rooms", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + roomManager.CreateRoom(new Room()); + roomManager.PartRoom(); + roomManager.CreateRoom(new Room()); + roomManager.PartRoom(); + }); + }); + + AddStep("disconnect", () => roomContainer.Client.Disconnect()); + + AddAssert("rooms cleared", () => roomManager.Rooms.Count == 0); + AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); + } + + [Test] + public void TestRoomsPolledOnReconnect() + { + AddStep("create room manager with a few rooms", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + roomManager.CreateRoom(new Room()); + roomManager.PartRoom(); + roomManager.CreateRoom(new Room()); + roomManager.PartRoom(); + }); + }); + + AddStep("disconnect", () => roomContainer.Client.Disconnect()); + AddStep("connect", () => roomContainer.Client.Connect()); + + AddAssert("manager polled for rooms", () => roomManager.Rooms.Count == 2); + AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); + } + + [Test] + public void TestRoomsNotPolledWhenJoined() + { + AddStep("create room manager with a room", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + roomManager.CreateRoom(new Room()); + roomManager.ClearRooms(); + }); + }); + + AddAssert("manager not polled for rooms", () => roomManager.Rooms.Count == 0); + AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); + } + + [Test] + public void TestMultiplayerRoomJoinedWhenCreated() + { + AddStep("create room manager with a room", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + roomManager.CreateRoom(new Room()); + }); + }); + + AddAssert("multiplayer room joined", () => roomContainer.Client.Room != null); + } + + [Test] + public void TestMultiplayerRoomPartedWhenAPIRoomParted() + { + AddStep("create room manager with a room", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + roomManager.CreateRoom(new Room()); + roomManager.PartRoom(); + }); + }); + + AddAssert("multiplayer room parted", () => roomContainer.Client.Room == null); + } + + [Test] + public void TestMultiplayerRoomJoinedWhenAPIRoomJoined() + { + AddStep("create room manager with a room", () => + { + createRoomManager().With(d => d.OnLoadComplete += _ => + { + var r = new Room(); + roomManager.CreateRoom(r); + roomManager.PartRoom(); + roomManager.JoinRoom(r); + }); + }); + + AddAssert("multiplayer room joined", () => roomContainer.Client.Room != null); + } + + private TestRealtimeRoomManager createRoomManager() + { + Child = roomContainer = new TestRealtimeRoomContainer + { + RoomManager = + { + TimeBetweenListingPolls = { Value = 1 }, + TimeBetweenSelectionPolls = { Value = 1 } + } + }; + + return roomManager; + } + } +} diff --git a/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.cs b/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.cs new file mode 100644 index 0000000000..1d82a5bc87 --- /dev/null +++ b/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.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 Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace osu.Game.IO.Serialization.Converters +{ + public class SnakeCaseStringEnumConverter : StringEnumConverter + { + public SnakeCaseStringEnumConverter() + { + NamingStrategy = new SnakeCaseNamingStrategy(); + } + } +} diff --git a/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs index 8e6deeb3c6..158ae03b8d 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs @@ -7,16 +7,16 @@ namespace osu.Game.Online.API.Requests { public class GetBeatmapSetRequest : APIRequest { - private readonly int id; - private readonly BeatmapSetLookupType type; + public readonly int ID; + public readonly BeatmapSetLookupType Type; public GetBeatmapSetRequest(int id, BeatmapSetLookupType type = BeatmapSetLookupType.SetId) { - this.id = id; - this.type = type; + ID = id; + Type = type; } - protected override string Target => type == BeatmapSetLookupType.SetId ? $@"beatmapsets/{id}" : $@"beatmapsets/lookup?beatmap_id={id}"; + protected override string Target => Type == BeatmapSetLookupType.SetId ? $@"beatmapsets/{ID}" : $@"beatmapsets/lookup?beatmap_id={ID}"; } public enum BeatmapSetLookupType diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index 6d0160fbc4..720d6bfff4 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -80,7 +80,7 @@ namespace osu.Game.Online.API.Requests.Responses public BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) { - return new BeatmapSetInfo + var beatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = OnlineBeatmapSetID, Metadata = this, @@ -104,8 +104,17 @@ namespace osu.Game.Online.API.Requests.Responses Genre = genre, Language = language }, - Beatmaps = beatmaps?.Select(b => b.ToBeatmap(rulesets)).ToList(), }; + + beatmapSet.Beatmaps = beatmaps?.Select(b => + { + var beatmap = b.ToBeatmap(rulesets); + beatmap.BeatmapSet = beatmapSet; + beatmap.Metadata = beatmapSet.Metadata; + return beatmap; + }).ToList(); + + return beatmapSet; } } } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 16f46581c5..62ae507419 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -57,7 +57,7 @@ namespace osu.Game.Online.Chat { CurrentChannel.ValueChanged += currentChannelChanged; - HighPollRate.BindValueChanged(enabled => TimeBetweenPolls = enabled.NewValue ? 1000 : 6000, true); + HighPollRate.BindValueChanged(enabled => TimeBetweenPolls.Value = enabled.NewValue ? 1000 : 6000, true); } /// diff --git a/osu.Game/Online/Multiplayer/CreateRoomRequest.cs b/osu.Game/Online/Multiplayer/CreateRoomRequest.cs index dcb4ed51ea..5be99e9442 100644 --- a/osu.Game/Online/Multiplayer/CreateRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/CreateRoomRequest.cs @@ -10,11 +10,11 @@ namespace osu.Game.Online.Multiplayer { public class CreateRoomRequest : APIRequest { - private readonly Room room; + public readonly Room Room; public CreateRoomRequest(Room room) { - this.room = room; + Room = room; } protected override WebRequest CreateWebRequest() @@ -24,7 +24,7 @@ namespace osu.Game.Online.Multiplayer req.ContentType = "application/json"; req.Method = HttpMethod.Post; - req.AddRaw(JsonConvert.SerializeObject(room)); + req.AddRaw(JsonConvert.SerializeObject(Room)); return req; } diff --git a/osu.Game/Online/Multiplayer/GetRoomRequest.cs b/osu.Game/Online/Multiplayer/GetRoomRequest.cs index 2907b49f1d..449c2c8e31 100644 --- a/osu.Game/Online/Multiplayer/GetRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/GetRoomRequest.cs @@ -7,13 +7,13 @@ namespace osu.Game.Online.Multiplayer { public class GetRoomRequest : APIRequest { - private readonly int roomId; + public readonly int RoomId; public GetRoomRequest(int roomId) { - this.roomId = roomId; + RoomId = roomId; } - protected override string Target => $"rooms/{roomId}"; + protected override string Target => $"rooms/{RoomId}"; } } diff --git a/osu.Game/Online/Multiplayer/Room.cs b/osu.Game/Online/Multiplayer/Room.cs index 9a21543b2e..11efe281d1 100644 --- a/osu.Game/Online/Multiplayer/Room.cs +++ b/osu.Game/Online/Multiplayer/Room.cs @@ -6,6 +6,7 @@ using System.Linq; using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.IO.Serialization.Converters; using osu.Game.Online.Multiplayer.GameTypes; using osu.Game.Online.Multiplayer.RoomStatuses; using osu.Game.Users; @@ -35,12 +36,21 @@ namespace osu.Game.Online.Multiplayer public readonly Bindable ChannelId = new Bindable(); [Cached] - [JsonProperty("category")] + [JsonIgnore] public readonly Bindable Category = new Bindable(); + // Todo: osu-framework bug (https://github.com/ppy/osu-framework/issues/4106) + [JsonProperty("category")] + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + private RoomCategory category + { + get => Category.Value; + set => Category.Value = value; + } + [Cached] [JsonIgnore] - public readonly Bindable Duration = new Bindable(TimeSpan.FromMinutes(30)); + public readonly Bindable Duration = new Bindable(); [Cached] [JsonIgnore] @@ -67,27 +77,26 @@ namespace osu.Game.Online.Multiplayer public readonly BindableList RecentParticipants = new BindableList(); [Cached] + [JsonProperty("participant_count")] public readonly Bindable ParticipantCount = new Bindable(); - // todo: TEMPORARY - [JsonProperty("participant_count")] - private int? participantCount - { - get => ParticipantCount.Value; - set => ParticipantCount.Value = value ?? 0; - } - [JsonProperty("duration")] - private int duration + private int? duration { - get => (int)Duration.Value.TotalMinutes; - set => Duration.Value = TimeSpan.FromMinutes(value); + get => (int?)Duration.Value?.TotalMinutes; + set + { + if (value == null) + Duration.Value = null; + else + Duration.Value = TimeSpan.FromMinutes(value.Value); + } } // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] - public readonly Bindable EndDate = new Bindable(); + public readonly Bindable EndDate = new Bindable(); // Todo: Find a better way to do this (https://github.com/ppy/osu-framework/issues/1930) [JsonProperty("max_attempts", DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -133,7 +142,7 @@ namespace osu.Game.Online.Multiplayer ParticipantCount.Value = other.ParticipantCount.Value; EndDate.Value = other.EndDate.Value; - if (DateTimeOffset.Now >= EndDate.Value) + if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); if (!Playlist.SequenceEqual(other.Playlist)) diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index 228f147835..3d19f2ab09 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -3,6 +3,7 @@ using System; using System.Threading.Tasks; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; @@ -19,22 +20,11 @@ namespace osu.Game.Online private bool pollingActive; - private double timeBetweenPolls; - /// /// The time in milliseconds to wait between polls. /// Setting to zero stops all polling. /// - public double TimeBetweenPolls - { - get => timeBetweenPolls; - set - { - timeBetweenPolls = value; - scheduledPoll?.Cancel(); - pollIfNecessary(); - } - } + public readonly Bindable TimeBetweenPolls = new Bindable(); /// /// @@ -42,7 +32,13 @@ namespace osu.Game.Online /// The initial time in milliseconds to wait between polls. Setting to zero stops all polling. protected PollingComponent(double timeBetweenPolls = 0) { - TimeBetweenPolls = timeBetweenPolls; + TimeBetweenPolls.BindValueChanged(_ => + { + scheduledPoll?.Cancel(); + pollIfNecessary(); + }); + + TimeBetweenPolls.Value = timeBetweenPolls; } protected override void LoadComplete() @@ -60,7 +56,7 @@ namespace osu.Game.Online if (pollingActive) return false; // don't try polling if the time between polls hasn't been set. - if (timeBetweenPolls == 0) return false; + if (TimeBetweenPolls.Value == 0) return false; if (!lastTimePolled.HasValue) { @@ -68,7 +64,7 @@ namespace osu.Game.Online return true; } - if (Time.Current - lastTimePolled.Value > timeBetweenPolls) + if (Time.Current - lastTimePolled.Value > TimeBetweenPolls.Value) { doPoll(); return true; @@ -99,7 +95,7 @@ namespace osu.Game.Online /// public void PollImmediately() { - lastTimePolled = Time.Current - timeBetweenPolls; + lastTimePolled = Time.Current - TimeBetweenPolls.Value; scheduleNextPoll(); } @@ -121,7 +117,7 @@ namespace osu.Game.Online double lastPollDuration = lastTimePolled.HasValue ? Time.Current - lastTimePolled.Value : 0; - scheduledPoll = Scheduler.AddDelayed(doPoll, Math.Max(0, timeBetweenPolls - lastPollDuration)); + scheduledPoll = Scheduler.AddDelayed(doPoll, Math.Max(0, TimeBetweenPolls.Value - lastPollDuration)); } } } diff --git a/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs new file mode 100644 index 0000000000..f56499f040 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs @@ -0,0 +1,408 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.RoomStatuses; +using osu.Game.Rulesets; +using osu.Game.Users; +using osu.Game.Utils; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer + { + /// + /// Invoked when any change occurs to the multiplayer room. + /// + public event Action? RoomChanged; + + /// + /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. + /// + public event Action? LoadRequested; + + /// + /// Invoked when the multiplayer server requests gameplay to be started. + /// + public event Action? MatchStarted; + + /// + /// Invoked when the multiplayer server has finished collating results. + /// + public event Action? ResultsReady; + + /// + /// Whether the is currently connected. + /// + public abstract IBindable IsConnected { get; } + + /// + /// The joined . + /// + public MultiplayerRoom? Room { get; private set; } + + /// + /// The users currently in gameplay. + /// + public readonly BindableList PlayingUsers = new BindableList(); + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private Room? apiRoom; + + // Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise. + private int playlistItemId; + + /// + /// Joins the for a given API . + /// + /// The API . + public async Task JoinRoom(Room room) + { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + + Debug.Assert(room.RoomID.Value != null); + + apiRoom = room; + playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0; + + Room = await JoinRoom(room.RoomID.Value.Value); + + Debug.Assert(Room != null); + + foreach (var user in Room.Users) + await PopulateUser(user); + + updateLocalRoomSettings(Room.Settings); + } + + /// + /// Joins the with a given ID. + /// + /// The room ID. + /// The joined . + protected abstract Task JoinRoom(long roomId); + + public virtual Task LeaveRoom() + { + if (Room == null) + return Task.CompletedTask; + + apiRoom = null; + Room = null; + + Schedule(() => RoomChanged?.Invoke()); + + return Task.CompletedTask; + } + + /// + /// Change the current settings. + /// + /// + /// A room must be joined for this to have any effect. + /// + /// The new room name, if any. + /// The new room playlist item, if any. + public void ChangeSettings(Optional name = default, Optional item = default) + { + if (Room == null) + return; + + // A dummy playlist item filled with the current room settings (except mods). + var existingPlaylistItem = new PlaylistItem + { + Beatmap = + { + Value = new BeatmapInfo + { + OnlineBeatmapID = Room.Settings.BeatmapID, + MD5Hash = Room.Settings.BeatmapChecksum + } + }, + RulesetID = Room.Settings.RulesetID + }; + + ChangeSettings(new MultiplayerRoomSettings + { + Name = name.GetOr(Room.Settings.Name), + BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, + BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, + RulesetID = item.GetOr(existingPlaylistItem).RulesetID, + Mods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods + }); + } + + public abstract Task TransferHost(int userId); + + public abstract Task ChangeSettings(MultiplayerRoomSettings settings); + + public abstract Task ChangeState(MultiplayerUserState newState); + + public abstract Task StartMatch(); + + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + Room.State = state; + + switch (state) + { + case MultiplayerRoomState.Open: + apiRoom.Status.Value = new RoomStatusOpen(); + break; + + case MultiplayerRoomState.Playing: + apiRoom.Status.Value = new RoomStatusPlaying(); + break; + + case MultiplayerRoomState.Closed: + apiRoom.Status.Value = new RoomStatusEnded(); + break; + } + + RoomChanged?.Invoke(); + }); + + return Task.CompletedTask; + } + + async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) + { + if (Room == null) + return; + + await PopulateUser(user); + + Schedule(() => + { + if (Room == null) + return; + + Room.Users.Add(user); + + RoomChanged?.Invoke(); + }); + } + + Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + Room.Users.Remove(user); + PlayingUsers.Remove(user.UserID); + + RoomChanged?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.HostChanged(int userId) + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + var user = Room.Users.FirstOrDefault(u => u.UserID == userId); + + Room.Host = user; + apiRoom.Host.Value = user?.User; + + RoomChanged?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) + { + updateLocalRoomSettings(newSettings); + return Task.CompletedTask; + } + + Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + Room.Users.Single(u => u.UserID == userId).State = state; + + if (state != MultiplayerUserState.Playing) + PlayingUsers.Remove(userId); + + RoomChanged?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.LoadRequested() + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + LoadRequested?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.MatchStarted() + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + PlayingUsers.AddRange(Room.Users.Where(u => u.State == MultiplayerUserState.Playing).Select(u => u.UserID)); + + MatchStarted?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.ResultsReady() + { + if (Room == null) + return Task.CompletedTask; + + Schedule(() => + { + if (Room == null) + return; + + ResultsReady?.Invoke(); + }); + + return Task.CompletedTask; + } + + /// + /// Populates the for a given . + /// + /// The to populate. + protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID); + + /// + /// Updates the local room settings with the given . + /// + /// + /// This updates both the joined and the respective API . + /// + /// The new to update from. + private void updateLocalRoomSettings(MultiplayerRoomSettings settings) + { + if (Room == null) + return; + + // Update a few properties of the room instantaneously. + Schedule(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + Room.Settings = settings; + apiRoom.Name.Value = Room.Settings.Name; + + // The playlist update is delayed until an online beatmap lookup (below) succeeds. + // In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here. + apiRoom.Playlist.Clear(); + + RoomChanged?.Invoke(); + }); + + var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId); + req.Success += res => updatePlaylist(settings, res); + + api.Queue(req); + } + + private void updatePlaylist(MultiplayerRoomSettings settings, APIBeatmapSet onlineSet) + { + if (Room == null || !Room.Settings.Equals(settings)) + return; + + Debug.Assert(apiRoom != null); + + var beatmapSet = onlineSet.ToBeatmapSet(rulesets); + var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); + beatmap.MD5Hash = settings.BeatmapChecksum; + + var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance(); + var mods = settings.Mods.Select(m => m.ToMod(ruleset)); + + PlaylistItem playlistItem = new PlaylistItem + { + ID = playlistItemId, + Beatmap = { Value = beatmap }, + Ruleset = { Value = ruleset.RulesetInfo }, + }; + + playlistItem.RequiredMods.AddRange(mods); + + apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity. + apiRoom.Playlist.Add(playlistItem); + } + } +} diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index d63faebae4..5926d11c03 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -103,7 +103,7 @@ namespace osu.Game.Overlays.Chat Colour = colours.ChatBlue.Lighten(0.7f), }; - private void newMessagesArrived(IEnumerable newMessages) + private void newMessagesArrived(IEnumerable newMessages) => Schedule(() => { if (newMessages.Min(m => m.Id) < chatLines.Max(c => c.Message.Id)) { @@ -155,9 +155,9 @@ namespace osu.Game.Overlays.Chat if (shouldScrollToEnd) scrollToEnd(); - } + }); - private void pendingMessageResolved(Message existing, Message updated) + private void pendingMessageResolved(Message existing, Message updated) => Schedule(() => { var found = chatLines.LastOrDefault(c => c.Message == existing); @@ -169,12 +169,12 @@ namespace osu.Game.Overlays.Chat found.Message = updated; ChatLineFlow.Add(found); } - } + }); - private void messageRemoved(Message removed) + private void messageRemoved(Message removed) => Schedule(() => { chatLines.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire(); - } + }); private IEnumerable chatLines => ChatLineFlow.Children.OfType(); diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 644c67ea59..da6da0ea97 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -303,7 +303,8 @@ namespace osu.Game.Rulesets.Objects.Drawables samplesBindable.CollectionChanged -= onSamplesChanged; // Release the samples for other hitobjects to use. - Samples.Samples = null; + if (Samples != null) + Samples.Samples = null; if (nestedHitObjects.IsValueCreated) { diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index a9b2a15b35..b13b20dae2 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -13,6 +13,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Game.Rulesets.Configuration; namespace osu.Game.Rulesets.UI @@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.UI if (resources != null) { - TextureStore = new TextureStore(new TextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + TextureStore = new TextureStore(parent.Get().CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); CacheAs(TextureStore = new FallbackTextureStore(TextureStore, parent.Get())); SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 2f14c607c2..1fc529910b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -25,10 +26,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private EditorBeatmap beatmap { get; set; } + [Resolved] + private OsuColour colours { get; set; } + private DragEvent lastDragEvent; private Bindable placement; private SelectionBlueprint placementBlueprint; + private readonly Box backgroundBox; + public TimelineBlueprintContainer(HitObjectComposer composer) : base(composer) { @@ -36,9 +42,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Anchor = Anchor.Centre; Origin = Anchor.Centre; - Height = 0.4f; + Height = 0.6f; - AddInternal(new Box + AddInternal(backgroundBox = new Box { Colour = Color4.Black, RelativeSizeAxes = Axes.Both, @@ -77,6 +83,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + protected override bool OnHover(HoverEvent e) + { + backgroundBox.FadeColour(colours.BlueLighter, 120, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + backgroundBox.FadeColour(Color4.Black, 600, Easing.OutQuint); + base.OnHoverLost(e); + } + protected override void OnDrag(DragEvent e) { handleScrollViaDrag(e); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 657c5834b2..ae2a82fa10 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { private const float thickness = 5; private const float shadow_radius = 5; - private const float circle_size = 24; + private const float circle_size = 34; public Action OnDragHandled; diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index ca7e5fbf20..223c678fba 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -461,7 +461,7 @@ namespace osu.Game.Screens.Edit if (dialogOverlay == null || dialogOverlay.CurrentDialog is PromptForSaveDialog) { confirmExit(); - return false; + return base.OnExiting(next); } if (isNewBeatmap || HasUnsavedChanges) diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index c3ecd75963..b781c347f0 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -17,7 +17,7 @@ using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; -using osu.Game.Screens.Multi; +using osu.Game.Screens.Multi.Timeshift; using osu.Game.Screens.Select; namespace osu.Game.Screens.Menu @@ -104,7 +104,7 @@ namespace osu.Game.Screens.Menu this.Push(new Editor()); }, OnSolo = onSolo, - OnMulti = delegate { this.Push(new Multiplayer()); }, + OnMulti = delegate { this.Push(new TimeshiftMultiplayer()); }, OnExit = confirmAndExit, } } diff --git a/osu.Game/Screens/Multi/Components/ListingPollingComponent.cs b/osu.Game/Screens/Multi/Components/ListingPollingComponent.cs new file mode 100644 index 0000000000..dff6c50bf2 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/ListingPollingComponent.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Screens.Multi.Components +{ + /// + /// A that polls for the lounge listing. + /// + public class ListingPollingComponent : RoomPollingComponent + { + [Resolved] + private Bindable currentFilter { get; set; } + + [Resolved] + private Bindable selectedRoom { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + currentFilter.BindValueChanged(_ => + { + NotifyRoomsReceived(null); + if (IsLoaded) + PollImmediately(); + }); + } + + private GetRoomsRequest pollReq; + + protected override Task Poll() + { + if (!API.IsLoggedIn) + return base.Poll(); + + var tcs = new TaskCompletionSource(); + + pollReq?.Cancel(); + pollReq = new GetRoomsRequest(currentFilter.Value.Status, currentFilter.Value.Category); + + pollReq.Success += result => + { + for (int i = 0; i < result.Count; i++) + { + if (result[i].RoomID.Value == selectedRoom.Value?.RoomID.Value) + { + // The listing request always has less information than the opened room, so don't include it. + result[i] = selectedRoom.Value; + break; + } + } + + NotifyRoomsReceived(result); + tcs.SetResult(true); + }; + + pollReq.Failure += _ => tcs.SetResult(false); + + API.Queue(pollReq); + + return tcs.Task; + } + } +} diff --git a/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs b/osu.Game/Screens/Multi/Components/ReadyButton.cs similarity index 77% rename from osu.Game/Screens/Multi/Match/Components/ReadyButton.cs rename to osu.Game/Screens/Multi/Components/ReadyButton.cs index a64f24dd7e..0bb4ed8617 100644 --- a/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs +++ b/osu.Game/Screens/Multi/Components/ReadyButton.cs @@ -11,28 +11,22 @@ using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; -namespace osu.Game.Screens.Multi.Match.Components +namespace osu.Game.Screens.Multi.Components { - public class ReadyButton : TriangleButton + public abstract class ReadyButton : TriangleButton { public readonly Bindable SelectedItem = new Bindable(); - [Resolved(typeof(Room), nameof(Room.EndDate))] - private Bindable endDate { get; set; } + public new readonly BindableBool Enabled = new BindableBool(); [Resolved] - private IBindable gameBeatmap { get; set; } + protected IBindable GameBeatmap { get; private set; } [Resolved] private BeatmapManager beatmaps { get; set; } private bool hasBeatmap; - public ReadyButton() - { - Text = "Start"; - } - private IBindable> managerUpdated; private IBindable> managerRemoved; @@ -45,10 +39,6 @@ namespace osu.Game.Screens.Multi.Match.Components managerRemoved.BindValueChanged(beatmapRemoved); SelectedItem.BindValueChanged(item => updateSelectedItem(item.NewValue), true); - - BackgroundColour = colours.Green; - Triangles.ColourDark = colours.Green; - Triangles.ColourLight = colours.GreenLight; } private void updateSelectedItem(PlaylistItem item) @@ -94,15 +84,13 @@ namespace osu.Game.Screens.Multi.Match.Components private void updateEnabledState() { - if (gameBeatmap.Value == null || SelectedItem.Value == null) + if (GameBeatmap.Value == null || SelectedItem.Value == null) { - Enabled.Value = false; + base.Enabled.Value = false; return; } - bool hasEnoughTime = DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(gameBeatmap.Value.Track.Length) < endDate.Value; - - Enabled.Value = hasBeatmap && hasEnoughTime; + base.Enabled.Value = hasBeatmap && Enabled.Value; } } } diff --git a/osu.Game/Screens/Multi/Components/RoomManager.cs b/osu.Game/Screens/Multi/Components/RoomManager.cs new file mode 100644 index 0000000000..482ee5492c --- /dev/null +++ b/osu.Game/Screens/Multi/Components/RoomManager.cs @@ -0,0 +1,204 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; + +namespace osu.Game.Screens.Multi.Components +{ + public abstract class RoomManager : CompositeDrawable, IRoomManager + { + public event Action RoomsUpdated; + + private readonly BindableList rooms = new BindableList(); + + public IBindable InitialRoomsReceived => initialRoomsReceived; + private readonly Bindable initialRoomsReceived = new Bindable(); + + public IBindableList Rooms => rooms; + + protected IBindable JoinedRoom => joinedRoom; + private readonly Bindable joinedRoom = new Bindable(); + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + protected RoomManager() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = CreatePollingComponents().Select(p => + { + p.RoomsReceived = onRoomsReceived; + return p; + }).ToList(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + PartRoom(); + } + + public virtual void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + { + room.Host.Value = api.LocalUser.Value; + + var req = new CreateRoomRequest(room); + + req.Success += result => + { + joinedRoom.Value = room; + + update(room, result); + addRoom(room); + + RoomsUpdated?.Invoke(); + onSuccess?.Invoke(room); + }; + + req.Failure += exception => + { + if (req.Result != null) + onError?.Invoke(req.Result.Error); + else + Logger.Log($"Failed to create the room: {exception}", level: LogLevel.Important); + }; + + api.Queue(req); + } + + private JoinRoomRequest currentJoinRoomRequest; + + public virtual void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + { + currentJoinRoomRequest?.Cancel(); + currentJoinRoomRequest = new JoinRoomRequest(room); + + currentJoinRoomRequest.Success += () => + { + joinedRoom.Value = room; + onSuccess?.Invoke(room); + }; + + currentJoinRoomRequest.Failure += exception => + { + if (!(exception is OperationCanceledException)) + Logger.Log($"Failed to join room: {exception}", level: LogLevel.Important); + onError?.Invoke(exception.ToString()); + }; + + api.Queue(currentJoinRoomRequest); + } + + public virtual void PartRoom() + { + currentJoinRoomRequest?.Cancel(); + + if (JoinedRoom.Value == null) + return; + + api.Queue(new PartRoomRequest(joinedRoom.Value)); + joinedRoom.Value = null; + } + + private readonly HashSet ignoredRooms = new HashSet(); + + private void onRoomsReceived(List received) + { + if (received == null) + { + ClearRooms(); + return; + } + + // Remove past matches + foreach (var r in rooms.ToList()) + { + if (received.All(e => e.RoomID.Value != r.RoomID.Value)) + rooms.Remove(r); + } + + for (int i = 0; i < received.Count; i++) + { + var room = received[i]; + + Debug.Assert(room.RoomID.Value != null); + + if (ignoredRooms.Contains(room.RoomID.Value.Value)) + continue; + + room.Position.Value = i; + + try + { + update(room, room); + addRoom(room); + } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); + + ignoredRooms.Add(room.RoomID.Value.Value); + rooms.Remove(room); + } + } + + RoomsUpdated?.Invoke(); + initialRoomsReceived.Value = true; + } + + protected void RemoveRoom(Room room) => rooms.Remove(room); + + protected void ClearRooms() + { + rooms.Clear(); + initialRoomsReceived.Value = false; + } + + /// + /// Updates a local with a remote copy. + /// + /// The local to update. + /// The remote to update with. + private void update(Room local, Room remote) + { + foreach (var pi in remote.Playlist) + pi.MapObjects(beatmaps, rulesets); + + local.CopyFrom(remote); + } + + /// + /// Adds a to the list of available rooms. + /// + /// The to add. + private void addRoom(Room room) + { + var existing = rooms.FirstOrDefault(e => e.RoomID.Value == room.RoomID.Value); + if (existing == null) + rooms.Add(room); + else + existing.CopyFrom(room); + } + + protected abstract IEnumerable CreatePollingComponents(); + } +} diff --git a/osu.Game/Screens/Multi/Components/RoomPollingComponent.cs b/osu.Game/Screens/Multi/Components/RoomPollingComponent.cs new file mode 100644 index 0000000000..fbaf9dd930 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/RoomPollingComponent.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 System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.Multi.Components +{ + public abstract class RoomPollingComponent : PollingComponent + { + /// + /// Invoked when any s have been received from the API. + /// + /// Any s present locally but not returned by this event are to be removed from display. + /// If null, the display of local rooms is reset to an initial state. + /// + /// + public Action> RoomsReceived; + + [Resolved] + protected IAPIProvider API { get; private set; } + + protected void NotifyRoomsReceived(List rooms) => RoomsReceived?.Invoke(rooms); + } +} diff --git a/osu.Game/Screens/Multi/Components/RoomStatusInfo.cs b/osu.Game/Screens/Multi/Components/RoomStatusInfo.cs index d799f846c2..b5676692a4 100644 --- a/osu.Game/Screens/Multi/Components/RoomStatusInfo.cs +++ b/osu.Game/Screens/Multi/Components/RoomStatusInfo.cs @@ -48,16 +48,23 @@ namespace osu.Game.Screens.Multi.Components private class EndDatePart : DrawableDate { - public readonly IBindable EndDate = new Bindable(); + public readonly IBindable EndDate = new Bindable(); public EndDatePart() : base(DateTimeOffset.UtcNow) { - EndDate.BindValueChanged(date => Date = date.NewValue); + EndDate.BindValueChanged(date => + { + // If null, set a very large future date to prevent unnecessary schedules. + Date = date.NewValue ?? DateTimeOffset.Now.AddYears(1); + }, true); } protected override string Format() { + if (EndDate.Value == null) + return string.Empty; + var diffToNow = Date.Subtract(DateTimeOffset.Now); if (diffToNow.TotalSeconds < -5) diff --git a/osu.Game/Screens/Multi/Components/SelectionPollingComponent.cs b/osu.Game/Screens/Multi/Components/SelectionPollingComponent.cs new file mode 100644 index 0000000000..37a190b5e0 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/SelectionPollingComponent.cs @@ -0,0 +1,69 @@ +// 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.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.Multi.Components +{ + /// + /// A that polls for the currently-selected room. + /// + public class SelectionPollingComponent : RoomPollingComponent + { + [Resolved] + private Bindable selectedRoom { get; set; } + + [Resolved] + private IRoomManager roomManager { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + selectedRoom.BindValueChanged(_ => + { + if (IsLoaded) + PollImmediately(); + }); + } + + private GetRoomRequest pollReq; + + protected override Task Poll() + { + if (!API.IsLoggedIn) + return base.Poll(); + + if (selectedRoom.Value?.RoomID.Value == null) + return base.Poll(); + + var tcs = new TaskCompletionSource(); + + pollReq?.Cancel(); + pollReq = new GetRoomRequest(selectedRoom.Value.RoomID.Value.Value); + + pollReq.Success += result => + { + var rooms = new List(roomManager.Rooms); + + int index = rooms.FindIndex(r => r.RoomID.Value == result.RoomID.Value); + if (index < 0) + return; + + rooms[index] = result; + + NotifyRoomsReceived(rooms); + tcs.SetResult(true); + }; + + pollReq.Failure += _ => tcs.SetResult(false); + + API.Queue(pollReq); + + return tcs.Task; + } + } +} diff --git a/osu.Game/Screens/Multi/IRoomManager.cs b/osu.Game/Screens/Multi/IRoomManager.cs index bf75843c3e..630e3af91c 100644 --- a/osu.Game/Screens/Multi/IRoomManager.cs +++ b/osu.Game/Screens/Multi/IRoomManager.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.Multi { + [Cached(typeof(IRoomManager))] public interface IRoomManager { /// @@ -17,7 +19,7 @@ namespace osu.Game.Screens.Multi /// /// Whether an initial listing of rooms has been received. /// - Bindable InitialRoomsReceived { get; } + IBindable InitialRoomsReceived { get; } /// /// All the active s. diff --git a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs index a26a64d86d..165a2b201c 100644 --- a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Multi.Lounge protected override UserActivity InitialActivity => new UserActivity.SearchingForLobby(); - private readonly Bindable initialRoomsReceived = new Bindable(); + private readonly IBindable initialRoomsReceived = new Bindable(); private Container content; private LoadingLayer loadingLayer; diff --git a/osu.Game/Screens/Multi/Match/Components/Footer.cs b/osu.Game/Screens/Multi/Match/Components/Footer.cs index be4ee873fa..d6a7e380bf 100644 --- a/osu.Game/Screens/Multi/Match/Components/Footer.cs +++ b/osu.Game/Screens/Multi/Match/Components/Footer.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi.Timeshift; using osuTK; namespace osu.Game.Screens.Multi.Match.Components @@ -31,7 +32,7 @@ namespace osu.Game.Screens.Multi.Match.Components InternalChildren = new[] { background = new Box { RelativeSizeAxes = Axes.Both }, - new ReadyButton + new TimeshiftReadyButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs index b8003b9774..1859e8db8a 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs @@ -325,7 +325,7 @@ namespace osu.Game.Screens.Multi.Match.Components Availability.BindValueChanged(availability => AvailabilityPicker.Current.Value = availability.NewValue, true); Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); - Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue, true); + Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true); playlist.Items.BindTo(Playlist); Playlist.BindCollectionChanged(onPlaylistChanged, true); diff --git a/osu.Game/Screens/Multi/Multiplayer.cs b/osu.Game/Screens/Multi/Multiplayer.cs index a323faeea1..837ccdf2e9 100644 --- a/osu.Game/Screens/Multi/Multiplayer.cs +++ b/osu.Game/Screens/Multi/Multiplayer.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; @@ -30,7 +29,7 @@ using osuTK; namespace osu.Game.Screens.Multi { [Cached] - public class Multiplayer : OsuScreen + public abstract class Multiplayer : OsuScreen { public override bool CursorVisible => (screenStack.CurrentScreen as IMultiplayerSubScreen)?.CursorVisible ?? true; @@ -46,6 +45,9 @@ namespace osu.Game.Screens.Multi private readonly IBindable isIdle = new BindableBool(); + [Cached(Type = typeof(IRoomManager))] + protected RoomManager RoomManager { get; private set; } + [Cached] private readonly Bindable selectedRoom = new Bindable(); @@ -55,9 +57,6 @@ namespace osu.Game.Screens.Multi [Resolved(CanBeNull = true)] private MusicController music { get; set; } - [Cached(Type = typeof(IRoomManager))] - private RoomManager roomManager; - [Resolved] private OsuGameBase game { get; set; } @@ -70,7 +69,7 @@ namespace osu.Game.Screens.Multi private readonly Drawable header; private readonly Drawable headerBackground; - public Multiplayer() + protected Multiplayer() { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -137,7 +136,7 @@ namespace osu.Game.Screens.Multi Origin = Anchor.TopRight, Action = () => CreateRoom() }, - roomManager = new RoomManager() + RoomManager = CreateRoomManager() } }; @@ -168,7 +167,7 @@ namespace osu.Game.Screens.Multi protected override void LoadComplete() { base.LoadComplete(); - isIdle.BindValueChanged(idle => updatePollingRate(idle.NewValue), true); + isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -178,36 +177,7 @@ namespace osu.Game.Screens.Multi return dependencies; } - private void updatePollingRate(bool idle) - { - if (!this.IsCurrentScreen()) - { - roomManager.TimeBetweenListingPolls = 0; - roomManager.TimeBetweenSelectionPolls = 0; - } - else - { - switch (screenStack.CurrentScreen) - { - case LoungeSubScreen _: - roomManager.TimeBetweenListingPolls = idle ? 120000 : 15000; - roomManager.TimeBetweenSelectionPolls = idle ? 120000 : 15000; - break; - - case MatchSubScreen _: - roomManager.TimeBetweenListingPolls = 0; - roomManager.TimeBetweenSelectionPolls = idle ? 30000 : 5000; - break; - - default: - roomManager.TimeBetweenListingPolls = 0; - roomManager.TimeBetweenSelectionPolls = 0; - break; - } - } - - Logger.Log($"Polling adjusted (listing: {roomManager.TimeBetweenListingPolls}, selection: {roomManager.TimeBetweenSelectionPolls})"); - } + protected abstract void UpdatePollingRate(bool isIdle); private void forcefullyExit() { @@ -241,7 +211,7 @@ namespace osu.Game.Screens.Multi beginHandlingTrack(); - updatePollingRate(isIdle.Value); + UpdatePollingRate(isIdle.Value); } public override void OnSuspending(IScreen next) @@ -251,12 +221,12 @@ namespace osu.Game.Screens.Multi endHandlingTrack(); - updatePollingRate(isIdle.Value); + UpdatePollingRate(isIdle.Value); } public override bool OnExiting(IScreen next) { - roomManager.PartRoom(); + RoomManager.PartRoom(); waves.Hide(); @@ -344,12 +314,14 @@ namespace osu.Game.Screens.Multi if (newScreen is IOsuScreen newOsuScreen) ((IBindable)Activity).BindTo(newOsuScreen.Activity); - updatePollingRate(isIdle.Value); + UpdatePollingRate(isIdle.Value); createButton.FadeTo(newScreen is LoungeSubScreen ? 1 : 0, 200); updateTrack(); } + protected IScreen CurrentSubScreen => screenStack.CurrentScreen; + private void updateTrack(ValueChangedEvent _ = null) { if (screenStack.CurrentScreen is MatchSubScreen) @@ -381,6 +353,8 @@ namespace osu.Game.Screens.Multi } } + protected abstract RoomManager CreateRoomManager(); + private class MultiplayerWaveContainer : WaveContainer { protected override bool StartHidden => true; diff --git a/osu.Game/Screens/Multi/MultiplayerComposite.cs b/osu.Game/Screens/Multi/MultiplayerComposite.cs index e612e77748..6e0c69d712 100644 --- a/osu.Game/Screens/Multi/MultiplayerComposite.cs +++ b/osu.Game/Screens/Multi/MultiplayerComposite.cs @@ -40,12 +40,12 @@ namespace osu.Game.Screens.Multi protected Bindable MaxParticipants { get; private set; } [Resolved(typeof(Room))] - protected Bindable EndDate { get; private set; } + protected Bindable EndDate { get; private set; } [Resolved(typeof(Room))] protected Bindable Availability { get; private set; } [Resolved(typeof(Room))] - protected Bindable Duration { get; private set; } + protected Bindable Duration { get; private set; } } } diff --git a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs index 0efa9c5196..41dcf61740 100644 --- a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs +++ b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; @@ -95,19 +96,36 @@ namespace osu.Game.Screens.Multi.Play return new TimeshiftResultsScreen(score, roomId.Value.Value, playlistItem, true); } - protected override ScoreInfo CreateScore() + protected override Score CreateScore() { var score = base.CreateScore(); - score.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); + score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); + return score; + } + + protected override async Task SubmitScore(Score score) + { + await base.SubmitScore(score); Debug.Assert(token != null); - var request = new SubmitRoomScoreRequest(token.Value, roomId.Value ?? 0, playlistItem.ID, score); - request.Success += s => score.OnlineScoreID = s.ID; - request.Failure += e => Logger.Error(e, "Failed to submit score"); - api.Queue(request); + var tcs = new TaskCompletionSource(); + var request = new SubmitRoomScoreRequest(token.Value, roomId.Value ?? 0, playlistItem.ID, score.ScoreInfo); - return score; + request.Success += s => + { + score.ScoreInfo.OnlineScoreID = s.ID; + tcs.SetResult(true); + }; + + request.Failure += e => + { + Logger.Error(e, "Failed to submit score"); + tcs.SetResult(false); + }; + + api.Queue(request); + await tcs.Task; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ParticipantPanel.cs new file mode 100644 index 0000000000..002849a275 --- /dev/null +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ParticipantPanel.cs @@ -0,0 +1,189 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Multi.RealtimeMultiplayer.Participants +{ + public class ParticipantPanel : RealtimeRoomComposite, IHasContextMenu + { + public readonly MultiplayerRoomUser User; + + [Resolved] + private IAPIProvider api { get; set; } + + private ReadyMark readyMark; + private SpriteIcon crown; + + public ParticipantPanel(MultiplayerRoomUser user) + { + User = user; + + RelativeSizeAxes = Axes.X; + Height = 40; + } + + [BackgroundDependencyLoader] + private void load() + { + Debug.Assert(User.User != null); + + var backgroundColour = Color4Extensions.FromHex("#33413C"); + + InternalChildren = new Drawable[] + { + crown = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Crown, + Size = new Vector2(14), + Colour = Color4Extensions.FromHex("#F7E65D"), + Alpha = 0 + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 24 }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new UserCoverBackground + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Width = 0.75f, + User = User.User, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White.Opacity(0.25f)) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(10), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new UpdateableAvatar + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + User = User.User + }, + new UpdateableFlag + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(30, 20), + Country = User.User.Country + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), + Text = User.User.Username + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 14), + Text = User.User.CurrentModeRank != null ? $"#{User.User.CurrentModeRank}" : string.Empty + } + } + }, + readyMark = new ReadyMark + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10 }, + Alpha = 0 + } + } + } + } + }; + } + + protected override void OnRoomChanged() + { + base.OnRoomChanged(); + + if (Room == null) + return; + + const double fade_time = 50; + + if (User.State == MultiplayerUserState.Ready) + readyMark.FadeIn(fade_time); + else + readyMark.FadeOut(fade_time); + + if (Room.Host?.Equals(User) == true) + crown.FadeIn(fade_time); + else + crown.FadeOut(fade_time); + } + + public MenuItem[] ContextMenuItems + { + get + { + if (Room == null) + return null; + + // If the local user is targetted. + if (User.UserID == api.LocalUser.Value.Id) + return null; + + // If the local user is not the host of the room. + if (Room.Host?.UserID != api.LocalUser.Value.Id) + return null; + + int targetUser = User.UserID; + + return new MenuItem[] + { + new OsuMenuItem("Give host", MenuItemType.Standard, () => + { + // Ensure the local user is still host. + if (Room.Host?.UserID != api.LocalUser.Value.Id) + return; + + Client.TransferHost(targetUser); + }) + }; + } + } + } +} diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ParticipantsList.cs new file mode 100644 index 0000000000..218c2cabb7 --- /dev/null +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ParticipantsList.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osuTK; + +namespace osu.Game.Screens.Multi.RealtimeMultiplayer.Participants +{ + public class ParticipantsList : RealtimeRoomComposite + { + private FillFlowContainer panels; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = panels = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2) + } + } + }; + } + + protected override void OnRoomChanged() + { + base.OnRoomChanged(); + + if (Room == null) + panels.Clear(); + else + { + // Remove panels for users no longer in the room. + panels.RemoveAll(p => !Room.Users.Contains(p.User)); + + // Add panels for all users new to the room. + foreach (var user in Room.Users.Except(panels.Select(p => p.User))) + panels.Add(new ParticipantPanel(user)); + } + } + } +} diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ReadyMark.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ReadyMark.cs new file mode 100644 index 0000000000..df49d9342e --- /dev/null +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/Participants/ReadyMark.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Multi.RealtimeMultiplayer.Participants +{ + public class ReadyMark : CompositeDrawable + { + public ReadyMark() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 12), + Text = "ready", + Colour = Color4Extensions.FromHex("#DDFFFF") + }, + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.CheckCircle, + Size = new Vector2(12), + Colour = Color4Extensions.FromHex("#AADD00") + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeReadyButton.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeReadyButton.cs new file mode 100644 index 0000000000..ea8fb04994 --- /dev/null +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeReadyButton.cs @@ -0,0 +1,123 @@ +// 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 System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Screens.Multi.Components; +using osuTK; + +namespace osu.Game.Screens.Multi.RealtimeMultiplayer +{ + public class RealtimeReadyButton : RealtimeRoomComposite + { + public Bindable SelectedItem => button.SelectedItem; + + [Resolved] + private IAPIProvider api { get; set; } + + [CanBeNull] + private MultiplayerRoomUser localUser; + + [Resolved] + private OsuColour colours { get; set; } + + private readonly ButtonWithTrianglesExposed button; + + public RealtimeReadyButton() + { + InternalChild = button = new ButtonWithTrianglesExposed + { + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Enabled = { Value = true }, + Action = onClick + }; + } + + protected override void OnRoomChanged() + { + base.OnRoomChanged(); + + localUser = Room?.Users.Single(u => u.User?.Id == api.LocalUser.Value.Id); + button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open; + updateState(); + } + + private void updateState() + { + if (localUser == null) + return; + + Debug.Assert(Room != null); + + switch (localUser.State) + { + case MultiplayerUserState.Idle: + button.Text = "Ready"; + updateButtonColour(true); + break; + + case MultiplayerUserState.Ready: + if (Room?.Host?.Equals(localUser) == true) + { + int countReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready); + button.Text = $"Start match ({countReady} / {Room.Users.Count} ready)"; + updateButtonColour(true); + } + else + { + button.Text = "Waiting for host..."; + updateButtonColour(false); + } + + break; + } + } + + private void updateButtonColour(bool green) + { + if (green) + { + button.BackgroundColour = colours.Green; + button.Triangles.ColourDark = colours.Green; + button.Triangles.ColourLight = colours.GreenLight; + } + else + { + button.BackgroundColour = colours.YellowDark; + button.Triangles.ColourDark = colours.YellowDark; + button.Triangles.ColourLight = colours.Yellow; + } + } + + private void onClick() + { + if (localUser == null) + return; + + if (localUser.State == MultiplayerUserState.Idle) + Client.ChangeState(MultiplayerUserState.Ready); + else + { + if (Room?.Host?.Equals(localUser) == true) + Client.StartMatch(); + else + Client.ChangeState(MultiplayerUserState.Idle); + } + } + + private class ButtonWithTrianglesExposed : ReadyButton + { + public new Triangles Triangles => base.Triangles; + } + } +} diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeRoomComposite.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeRoomComposite.cs new file mode 100644 index 0000000000..e6d1274316 --- /dev/null +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeRoomComposite.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Game.Online.RealtimeMultiplayer; + +namespace osu.Game.Screens.Multi.RealtimeMultiplayer +{ + public abstract class RealtimeRoomComposite : MultiplayerComposite + { + [CanBeNull] + protected MultiplayerRoom Room => Client.Room; + + [Resolved] + protected StatefulMultiplayerClient Client { get; private set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Client.RoomChanged += OnRoomChanged; + OnRoomChanged(); + } + + protected virtual void OnRoomChanged() + { + } + + protected override void Dispose(bool isDisposing) + { + if (Client != null) + Client.RoomChanged -= OnRoomChanged; + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeRoomManager.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeRoomManager.cs new file mode 100644 index 0000000000..e0fca3ce4c --- /dev/null +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimeRoomManager.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 System.Diagnostics; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Screens.Multi.Components; + +namespace osu.Game.Screens.Multi.RealtimeMultiplayer +{ + public class RealtimeRoomManager : RoomManager + { + [Resolved] + private StatefulMultiplayerClient multiplayerClient { get; set; } + + public readonly Bindable TimeBetweenListingPolls = new Bindable(); + public readonly Bindable TimeBetweenSelectionPolls = new Bindable(); + private readonly IBindable isConnected = new Bindable(); + private readonly Bindable allowPolling = new Bindable(); + + private ListingPollingComponent listingPollingComponent; + + protected override void LoadComplete() + { + base.LoadComplete(); + + isConnected.BindTo(multiplayerClient.IsConnected); + isConnected.BindValueChanged(_ => Schedule(updatePolling)); + JoinedRoom.BindValueChanged(_ => updatePolling()); + + updatePolling(); + } + + public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + => base.CreateRoom(room, r => joinMultiplayerRoom(r, onSuccess), onError); + + public override void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + => base.JoinRoom(room, r => joinMultiplayerRoom(r, onSuccess), onError); + + public override void PartRoom() + { + if (JoinedRoom.Value == null) + return; + + var joinedRoom = JoinedRoom.Value; + + base.PartRoom(); + multiplayerClient.LeaveRoom(); + + // Todo: This is not the way to do this. Basically when we're the only participant and the room closes, there's no way to know if this is actually the case. + // This is delayed one frame because upon exiting the match subscreen, multiplayer updates the polling rate and messes with polling. + Schedule(() => + { + RemoveRoom(joinedRoom); + listingPollingComponent.PollImmediately(); + }); + } + + private void joinMultiplayerRoom(Room room, Action onSuccess = null) + { + Debug.Assert(room.RoomID.Value != null); + + var joinTask = multiplayerClient.JoinRoom(room); + joinTask.ContinueWith(_ => onSuccess?.Invoke(room), TaskContinuationOptions.OnlyOnRanToCompletion); + joinTask.ContinueWith(t => + { + PartRoom(); + if (t.Exception != null) + Logger.Error(t.Exception, "Failed to join multiplayer room."); + }, TaskContinuationOptions.NotOnRanToCompletion); + } + + private void updatePolling() + { + if (!isConnected.Value) + ClearRooms(); + + // Don't poll when not connected or when a room has been joined. + allowPolling.Value = isConnected.Value && JoinedRoom.Value == null; + } + + protected override IEnumerable CreatePollingComponents() => new RoomPollingComponent[] + { + listingPollingComponent = new RealtimeListingPollingComponent + { + TimeBetweenPolls = { BindTarget = TimeBetweenListingPolls }, + AllowPolling = { BindTarget = allowPolling } + }, + new RealtimeSelectionPollingComponent + { + TimeBetweenPolls = { BindTarget = TimeBetweenSelectionPolls }, + AllowPolling = { BindTarget = allowPolling } + } + }; + + private class RealtimeListingPollingComponent : ListingPollingComponent + { + public readonly IBindable AllowPolling = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + AllowPolling.BindValueChanged(allowPolling => + { + if (!allowPolling.NewValue) + return; + + if (IsLoaded) + PollImmediately(); + }); + } + + protected override Task Poll() => !AllowPolling.Value ? Task.CompletedTask : base.Poll(); + } + + private class RealtimeSelectionPollingComponent : SelectionPollingComponent + { + public readonly IBindable AllowPolling = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + AllowPolling.BindValueChanged(allowPolling => + { + if (!allowPolling.NewValue) + return; + + if (IsLoaded) + PollImmediately(); + }); + } + + protected override Task Poll() => !AllowPolling.Value ? Task.CompletedTask : base.Poll(); + } + } +} diff --git a/osu.Game/Screens/Multi/RoomManager.cs b/osu.Game/Screens/Multi/RoomManager.cs deleted file mode 100644 index fb0cf73bb9..0000000000 --- a/osu.Game/Screens/Multi/RoomManager.cs +++ /dev/null @@ -1,337 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Logging; -using osu.Game.Beatmaps; -using osu.Game.Online; -using osu.Game.Online.API; -using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets; -using osu.Game.Screens.Multi.Lounge.Components; - -namespace osu.Game.Screens.Multi -{ - public class RoomManager : CompositeDrawable, IRoomManager - { - public event Action RoomsUpdated; - - private readonly BindableList rooms = new BindableList(); - - public Bindable InitialRoomsReceived { get; } = new Bindable(); - - public IBindableList Rooms => rooms; - - public double TimeBetweenListingPolls - { - get => listingPollingComponent.TimeBetweenPolls; - set => listingPollingComponent.TimeBetweenPolls = value; - } - - public double TimeBetweenSelectionPolls - { - get => selectionPollingComponent.TimeBetweenPolls; - set => selectionPollingComponent.TimeBetweenPolls = value; - } - - [Resolved] - private RulesetStore rulesets { get; set; } - - [Resolved] - private BeatmapManager beatmaps { get; set; } - - [Resolved] - private IAPIProvider api { get; set; } - - [Resolved] - private Bindable selectedRoom { get; set; } - - private readonly ListingPollingComponent listingPollingComponent; - private readonly SelectionPollingComponent selectionPollingComponent; - - private Room joinedRoom; - - public RoomManager() - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - listingPollingComponent = new ListingPollingComponent - { - InitialRoomsReceived = { BindTarget = InitialRoomsReceived }, - RoomsReceived = onListingReceived - }, - selectionPollingComponent = new SelectionPollingComponent { RoomReceived = onSelectedRoomReceived } - }; - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - PartRoom(); - } - - public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) - { - room.Host.Value = api.LocalUser.Value; - - var req = new CreateRoomRequest(room); - - req.Success += result => - { - joinedRoom = room; - - update(room, result); - addRoom(room); - - RoomsUpdated?.Invoke(); - onSuccess?.Invoke(room); - }; - - req.Failure += exception => - { - if (req.Result != null) - onError?.Invoke(req.Result.Error); - else - Logger.Log($"Failed to create the room: {exception}", level: LogLevel.Important); - }; - - api.Queue(req); - } - - private JoinRoomRequest currentJoinRoomRequest; - - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) - { - currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room); - - currentJoinRoomRequest.Success += () => - { - joinedRoom = room; - onSuccess?.Invoke(room); - }; - - currentJoinRoomRequest.Failure += exception => - { - if (!(exception is OperationCanceledException)) - Logger.Log($"Failed to join room: {exception}", level: LogLevel.Important); - onError?.Invoke(exception.ToString()); - }; - - api.Queue(currentJoinRoomRequest); - } - - public void PartRoom() - { - currentJoinRoomRequest?.Cancel(); - - if (joinedRoom == null) - return; - - api.Queue(new PartRoomRequest(joinedRoom)); - joinedRoom = null; - } - - private readonly HashSet ignoredRooms = new HashSet(); - - /// - /// Invoked when the listing of all s is received from the server. - /// - /// The listing. - private void onListingReceived(List listing) - { - // Remove past matches - foreach (var r in rooms.ToList()) - { - if (listing.All(e => e.RoomID.Value != r.RoomID.Value)) - rooms.Remove(r); - } - - for (int i = 0; i < listing.Count; i++) - { - if (selectedRoom.Value?.RoomID?.Value == listing[i].RoomID.Value) - { - // The listing request contains less data than the selection request, so data from the selection request is always preferred while the room is selected. - continue; - } - - var room = listing[i]; - - Debug.Assert(room.RoomID.Value != null); - - if (ignoredRooms.Contains(room.RoomID.Value.Value)) - continue; - - room.Position.Value = i; - - try - { - update(room, room); - addRoom(room); - } - catch (Exception ex) - { - Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); - - ignoredRooms.Add(room.RoomID.Value.Value); - rooms.Remove(room); - } - } - - RoomsUpdated?.Invoke(); - } - - /// - /// Invoked when a is received from the server. - /// - /// The received . - private void onSelectedRoomReceived(Room toUpdate) - { - foreach (var room in rooms) - { - if (room.RoomID.Value == toUpdate.RoomID.Value) - { - toUpdate.Position.Value = room.Position.Value; - update(room, toUpdate); - break; - } - } - } - - /// - /// Updates a local with a remote copy. - /// - /// The local to update. - /// The remote to update with. - private void update(Room local, Room remote) - { - foreach (var pi in remote.Playlist) - pi.MapObjects(beatmaps, rulesets); - - local.CopyFrom(remote); - } - - /// - /// Adds a to the list of available rooms. - /// - /// The to add. - private void addRoom(Room room) - { - var existing = rooms.FirstOrDefault(e => e.RoomID.Value == room.RoomID.Value); - if (existing == null) - rooms.Add(room); - else - existing.CopyFrom(room); - } - - private class SelectionPollingComponent : PollingComponent - { - public Action RoomReceived; - - [Resolved] - private IAPIProvider api { get; set; } - - [Resolved] - private Bindable selectedRoom { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - selectedRoom.BindValueChanged(_ => - { - if (IsLoaded) - PollImmediately(); - }); - } - - private GetRoomRequest pollReq; - - protected override Task Poll() - { - if (!api.IsLoggedIn) - return base.Poll(); - - if (selectedRoom.Value?.RoomID.Value == null) - return base.Poll(); - - var tcs = new TaskCompletionSource(); - - pollReq?.Cancel(); - pollReq = new GetRoomRequest(selectedRoom.Value.RoomID.Value.Value); - - pollReq.Success += result => - { - RoomReceived?.Invoke(result); - tcs.SetResult(true); - }; - - pollReq.Failure += _ => tcs.SetResult(false); - - api.Queue(pollReq); - - return tcs.Task; - } - } - - private class ListingPollingComponent : PollingComponent - { - public Action> RoomsReceived; - - public readonly Bindable InitialRoomsReceived = new Bindable(); - - [Resolved] - private IAPIProvider api { get; set; } - - [Resolved] - private Bindable currentFilter { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - currentFilter.BindValueChanged(_ => - { - InitialRoomsReceived.Value = false; - - if (IsLoaded) - PollImmediately(); - }); - } - - private GetRoomsRequest pollReq; - - protected override Task Poll() - { - if (!api.IsLoggedIn) - return base.Poll(); - - var tcs = new TaskCompletionSource(); - - pollReq?.Cancel(); - pollReq = new GetRoomsRequest(currentFilter.Value.Status, currentFilter.Value.Category); - - pollReq.Success += result => - { - InitialRoomsReceived.Value = true; - RoomsReceived?.Invoke(result); - tcs.SetResult(true); - }; - - pollReq.Failure += _ => tcs.SetResult(false); - - api.Queue(pollReq); - - return tcs.Task; - } - } - } -} diff --git a/osu.Game/Screens/Multi/Timeshift/TimeshiftMultiplayer.cs b/osu.Game/Screens/Multi/Timeshift/TimeshiftMultiplayer.cs new file mode 100644 index 0000000000..d2d6a35a2e --- /dev/null +++ b/osu.Game/Screens/Multi/Timeshift/TimeshiftMultiplayer.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Screens.Multi.Components; +using osu.Game.Screens.Multi.Lounge; +using osu.Game.Screens.Multi.Match; + +namespace osu.Game.Screens.Multi.Timeshift +{ + public class TimeshiftMultiplayer : Multiplayer + { + protected override void UpdatePollingRate(bool isIdle) + { + var timeshiftManager = (TimeshiftRoomManager)RoomManager; + + if (!this.IsCurrentScreen()) + { + timeshiftManager.TimeBetweenListingPolls.Value = 0; + timeshiftManager.TimeBetweenSelectionPolls.Value = 0; + } + else + { + switch (CurrentSubScreen) + { + case LoungeSubScreen _: + timeshiftManager.TimeBetweenListingPolls.Value = isIdle ? 120000 : 15000; + timeshiftManager.TimeBetweenSelectionPolls.Value = isIdle ? 120000 : 15000; + break; + + case MatchSubScreen _: + timeshiftManager.TimeBetweenListingPolls.Value = 0; + timeshiftManager.TimeBetweenSelectionPolls.Value = isIdle ? 30000 : 5000; + break; + + default: + timeshiftManager.TimeBetweenListingPolls.Value = 0; + timeshiftManager.TimeBetweenSelectionPolls.Value = 0; + break; + } + } + + Logger.Log($"Polling adjusted (listing: {timeshiftManager.TimeBetweenListingPolls.Value}, selection: {timeshiftManager.TimeBetweenSelectionPolls.Value})"); + } + + protected override RoomManager CreateRoomManager() => new TimeshiftRoomManager(); + } +} diff --git a/osu.Game/Screens/Multi/Timeshift/TimeshiftReadyButton.cs b/osu.Game/Screens/Multi/Timeshift/TimeshiftReadyButton.cs new file mode 100644 index 0000000000..c878451eee --- /dev/null +++ b/osu.Game/Screens/Multi/Timeshift/TimeshiftReadyButton.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 System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi.Components; + +namespace osu.Game.Screens.Multi.Timeshift +{ + public class TimeshiftReadyButton : ReadyButton + { + [Resolved(typeof(Room), nameof(Room.EndDate))] + private Bindable endDate { get; set; } + + public TimeshiftReadyButton() + { + Text = "Start"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.Green; + Triangles.ColourDark = colours.Green; + Triangles.ColourLight = colours.GreenLight; + } + + protected override void Update() + { + base.Update(); + + Enabled.Value = endDate.Value != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(GameBeatmap.Value.Track.Length) < endDate.Value; + } + } +} diff --git a/osu.Game/Screens/Multi/Timeshift/TimeshiftRoomManager.cs b/osu.Game/Screens/Multi/Timeshift/TimeshiftRoomManager.cs new file mode 100644 index 0000000000..d21f844e04 --- /dev/null +++ b/osu.Game/Screens/Multi/Timeshift/TimeshiftRoomManager.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Game.Screens.Multi.Components; + +namespace osu.Game.Screens.Multi.Timeshift +{ + public class TimeshiftRoomManager : RoomManager + { + public readonly Bindable TimeBetweenListingPolls = new Bindable(); + public readonly Bindable TimeBetweenSelectionPolls = new Bindable(); + + protected override IEnumerable CreatePollingComponents() => new RoomPollingComponent[] + { + new ListingPollingComponent { TimeBetweenPolls = { BindTarget = TimeBetweenListingPolls } }, + new SelectionPollingComponent { TimeBetweenPolls = { BindTarget = TimeBetweenSelectionPolls } } + }; + } +} diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index a54f9fc047..c539dff5d9 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -22,8 +23,10 @@ using osu.Game.Graphics.Containers; using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Overlays; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -501,6 +504,7 @@ namespace osu.Game.Screens.Play } private ScheduledDelegate completionProgressDelegate; + private Task scoreSubmissionTask; private void updateCompletionState(ValueChangedEvent completionState) { @@ -527,33 +531,50 @@ namespace osu.Game.Screens.Play if (!showResults) return; - using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) - completionProgressDelegate = Schedule(GotoRanking); - } - - protected virtual ScoreInfo CreateScore() - { - var score = new ScoreInfo + scoreSubmissionTask ??= Task.Run(async () => { - Beatmap = Beatmap.Value.BeatmapInfo, - Ruleset = rulesetInfo, - Mods = Mods.Value.ToArray(), - }; + var score = CreateScore(); - if (DrawableRuleset.ReplayScore != null) - score.User = DrawableRuleset.ReplayScore.ScoreInfo?.User ?? new GuestUser(); - else - score.User = api.LocalUser.Value; + try + { + await SubmitScore(score); + } + catch (Exception ex) + { + Logger.Error(ex, "Score submission failed!"); + } - ScoreProcessor.PopulateScore(score); + try + { + await ImportScore(score); + } + catch (Exception ex) + { + Logger.Error(ex, "Score import failed!"); + } - return score; + return score.ScoreInfo; + }); + + using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) + scheduleCompletion(); } + private void scheduleCompletion() => completionProgressDelegate = Schedule(() => + { + if (!scoreSubmissionTask.IsCompleted) + { + scheduleCompletion(); + return; + } + + // screen may be in the exiting transition phase. + if (this.IsCurrentScreen()) + this.Push(CreateResults(scoreSubmissionTask.Result)); + }); + protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; - protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, true); - #region Fail Logic protected FailOverlay FailOverlay { get; private set; } @@ -748,39 +769,74 @@ namespace osu.Game.Screens.Play return base.OnExiting(next); } - protected virtual void GotoRanking() + /// + /// Creates the player's . + /// + /// The . + protected virtual Score CreateScore() { + var score = new Score + { + ScoreInfo = new ScoreInfo + { + Beatmap = Beatmap.Value.BeatmapInfo, + Ruleset = rulesetInfo, + Mods = Mods.Value.ToArray(), + } + }; + if (DrawableRuleset.ReplayScore != null) { - // if a replay is present, we likely don't want to import into the local database. - this.Push(CreateResults(CreateScore())); - return; + score.ScoreInfo.User = DrawableRuleset.ReplayScore.ScoreInfo?.User ?? new GuestUser(); + score.Replay = DrawableRuleset.ReplayScore.Replay; } - - LegacyByteArrayReader replayReader = null; - - var score = new Score { ScoreInfo = CreateScore() }; - - if (recordingScore?.Replay.Frames.Count > 0) + else { - score.Replay = recordingScore.Replay; - - using (var stream = new MemoryStream()) - { - new LegacyScoreEncoder(score, gameplayBeatmap.PlayableBeatmap).Encode(stream); - replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); - } + score.ScoreInfo.User = api.LocalUser.Value; + score.Replay = new Replay { Frames = recordingScore?.Replay.Frames.ToList() ?? new List() }; } - scoreManager.Import(score.ScoreInfo, replayReader) - .ContinueWith(imported => Schedule(() => - { - // screen may be in the exiting transition phase. - if (this.IsCurrentScreen()) - this.Push(CreateResults(imported.Result)); - })); + ScoreProcessor.PopulateScore(score.ScoreInfo); + + return score; } + /// + /// Imports the player's to the local database. + /// + /// The to import. + /// The imported score. + protected virtual Task ImportScore(Score score) + { + // Replays are already populated and present in the game's database, so should not be re-imported. + if (DrawableRuleset.ReplayScore != null) + return Task.CompletedTask; + + LegacyByteArrayReader replayReader; + + using (var stream = new MemoryStream()) + { + new LegacyScoreEncoder(score, gameplayBeatmap.PlayableBeatmap).Encode(stream); + replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); + } + + return scoreManager.Import(score.ScoreInfo, replayReader); + } + + /// + /// Submits the player's . + /// + /// The to submit. + /// The submitted score. + protected virtual Task SubmitScore(Score score) => Task.CompletedTask; + + /// + /// Creates the for a . + /// + /// The to be displayed in the results screen. + /// The . + protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, true); + private void fadeOut(bool instant = false) { float fadeOutDuration = instant ? 0 : 250; diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 294d116f51..a07213cb33 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.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.Threading.Tasks; using osu.Framework.Input.Bindings; using osu.Game.Input.Bindings; using osu.Game.Scoring; @@ -26,18 +27,21 @@ namespace osu.Game.Screens.Play DrawableRuleset?.SetReplayScore(Score); } - protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); - - protected override ScoreInfo CreateScore() + protected override Score CreateScore() { var baseScore = base.CreateScore(); // Since the replay score doesn't contain statistics, we'll pass them through here. - Score.ScoreInfo.HitEvents = baseScore.HitEvents; + Score.ScoreInfo.HitEvents = baseScore.ScoreInfo.HitEvents; - return Score.ScoreInfo; + return Score; } + // Don't re-import replay scores as they're already present in the database. + protected override Task ImportScore(Score score) => Task.CompletedTask; + + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); + public bool OnPressed(GlobalAction action) { switch (action) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 4bc28e6cef..af4615c895 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; +using osu.Framework.Platform; using osu.Game.IO; using osu.Game.Screens.Play; @@ -59,12 +60,12 @@ namespace osu.Game.Storyboards.Drawables } [BackgroundDependencyLoader(true)] - private void load(FileStore fileStore, GameplayClock clock, CancellationToken? cancellationToken) + private void load(FileStore fileStore, GameplayClock clock, CancellationToken? cancellationToken, GameHost host) { if (clock != null) Clock = clock; - dependencies.Cache(new TextureStore(new TextureLoaderStore(fileStore.Store), false, scaleAdjust: 1)); + dependencies.Cache(new TextureStore(host.CreateTextureLoaderStore(fileStore.Store), false, scaleAdjust: 1)); foreach (var layer in Storyboard.Layers) { diff --git a/osu.Game/Tests/Visual/RealtimeMultiplayer/RealtimeMultiplayerTestScene.cs b/osu.Game/Tests/Visual/RealtimeMultiplayer/RealtimeMultiplayerTestScene.cs new file mode 100644 index 0000000000..aec70d8be4 --- /dev/null +++ b/osu.Game/Tests/Visual/RealtimeMultiplayer/RealtimeMultiplayerTestScene.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Screens.Multi.Lounge.Components; +using osu.Game.Screens.Multi.RealtimeMultiplayer; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + public abstract class RealtimeMultiplayerTestScene : MultiplayerTestScene + { + [Cached(typeof(StatefulMultiplayerClient))] + public TestRealtimeMultiplayerClient Client { get; } + + [Cached(typeof(RealtimeRoomManager))] + public TestRealtimeRoomManager RoomManager { get; } + + [Cached] + public Bindable Filter { get; } + + protected override Container Content => content; + private readonly TestRealtimeRoomContainer content; + + private readonly bool joinRoom; + + protected RealtimeMultiplayerTestScene(bool joinRoom = true) + { + this.joinRoom = joinRoom; + base.Content.Add(content = new TestRealtimeRoomContainer { RelativeSizeAxes = Axes.Both }); + + Client = content.Client; + RoomManager = content.RoomManager; + Filter = content.Filter; + } + + [SetUp] + public new void Setup() => Schedule(() => + { + RoomManager.Schedule(() => RoomManager.PartRoom()); + + if (joinRoom) + RoomManager.Schedule(() => RoomManager.CreateRoom(Room)); + }); + } +} diff --git a/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs new file mode 100644 index 0000000000..de52633c88 --- /dev/null +++ b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs @@ -0,0 +1,119 @@ +// 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.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.API; +using osu.Game.Online.RealtimeMultiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + public class TestRealtimeMultiplayerClient : StatefulMultiplayerClient + { + public override IBindable IsConnected => isConnected; + private readonly Bindable isConnected = new Bindable(true); + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public void Connect() => isConnected.Value = true; + + public void Disconnect() => isConnected.Value = false; + + public void AddUser(User user) => ((IMultiplayerClient)this).UserJoined(new MultiplayerRoomUser(user.Id) { User = user }); + + public void RemoveUser(User user) + { + Debug.Assert(Room != null); + ((IMultiplayerClient)this).UserLeft(Room.Users.Single(u => u.User == user)); + + Schedule(() => + { + if (Room.Users.Any()) + TransferHost(Room.Users.First().UserID); + }); + } + + public void ChangeUserState(int userId, MultiplayerUserState newState) + { + Debug.Assert(Room != null); + + ((IMultiplayerClient)this).UserStateChanged(userId, newState); + + Schedule(() => + { + switch (newState) + { + case MultiplayerUserState.Loaded: + if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) + { + foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.Loaded)) + ChangeUserState(u.UserID, MultiplayerUserState.Playing); + + ((IMultiplayerClient)this).MatchStarted(); + } + + break; + + case MultiplayerUserState.FinishedPlay: + if (Room.Users.All(u => u.State != MultiplayerUserState.Playing)) + { + foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay)) + ChangeUserState(u.UserID, MultiplayerUserState.Results); + + ((IMultiplayerClient)this).ResultsReady(); + } + + break; + } + }); + } + + protected override Task JoinRoom(long roomId) + { + var user = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value }; + + var room = new MultiplayerRoom(roomId); + room.Users.Add(user); + + if (room.Users.Count == 1) + room.Host = user; + + return Task.FromResult(room); + } + + public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); + + public override async Task ChangeSettings(MultiplayerRoomSettings settings) + { + Debug.Assert(Room != null); + + await ((IMultiplayerClient)this).SettingsChanged(settings); + + foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) + ChangeUserState(user.UserID, MultiplayerUserState.Idle); + } + + public override Task ChangeState(MultiplayerUserState newState) + { + ChangeUserState(api.LocalUser.Value.Id, newState); + return Task.CompletedTask; + } + + public override Task StartMatch() + { + Debug.Assert(Room != null); + + foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) + ChangeUserState(user.UserID, MultiplayerUserState.WaitingForLoad); + + return ((IMultiplayerClient)this).LoadRequested(); + } + } +} diff --git a/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeRoomContainer.cs b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeRoomContainer.cs new file mode 100644 index 0000000000..aa75968cca --- /dev/null +++ b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeRoomContainer.cs @@ -0,0 +1,40 @@ +// 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.Online.RealtimeMultiplayer; +using osu.Game.Screens.Multi.Lounge.Components; +using osu.Game.Screens.Multi.RealtimeMultiplayer; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + public class TestRealtimeRoomContainer : Container + { + protected override Container Content => content; + private readonly Container content; + + [Cached(typeof(StatefulMultiplayerClient))] + public readonly TestRealtimeMultiplayerClient Client; + + [Cached(typeof(RealtimeRoomManager))] + public readonly TestRealtimeRoomManager RoomManager; + + [Cached] + public readonly Bindable Filter = new Bindable(new FilterCriteria()); + + public TestRealtimeRoomContainer() + { + RelativeSizeAxes = Axes.Both; + + AddRangeInternal(new Drawable[] + { + Client = new TestRealtimeMultiplayerClient(), + RoomManager = new TestRealtimeRoomManager(), + content = new Container { RelativeSizeAxes = Axes.Both } + }); + } + } +} diff --git a/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeRoomManager.cs b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeRoomManager.cs new file mode 100644 index 0000000000..0d1314fb51 --- /dev/null +++ b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeRoomManager.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Multi.Lounge.Components; +using osu.Game.Screens.Multi.RealtimeMultiplayer; + +namespace osu.Game.Tests.Visual.RealtimeMultiplayer +{ + public class TestRealtimeRoomManager : RealtimeRoomManager + { + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private OsuGameBase game { get; set; } + + [Cached] + public readonly Bindable Filter = new Bindable(new FilterCriteria()); + + private readonly List rooms = new List(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + int currentScoreId = 0; + int currentRoomId = 0; + + ((DummyAPIAccess)api).HandleRequest = req => + { + switch (req) + { + case CreateRoomRequest createRoomRequest: + var createdRoom = new APICreatedRoom(); + + createdRoom.CopyFrom(createRoomRequest.Room); + createdRoom.RoomID.Value ??= currentRoomId++; + + rooms.Add(createdRoom); + createRoomRequest.TriggerSuccess(createdRoom); + break; + + case JoinRoomRequest joinRoomRequest: + joinRoomRequest.TriggerSuccess(); + break; + + case PartRoomRequest partRoomRequest: + partRoomRequest.TriggerSuccess(); + break; + + case GetRoomsRequest getRoomsRequest: + var roomsWithoutParticipants = new List(); + + foreach (var r in rooms) + { + var newRoom = new Room(); + + newRoom.CopyFrom(r); + newRoom.RecentParticipants.Clear(); + + roomsWithoutParticipants.Add(newRoom); + } + + getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); + break; + + case GetRoomRequest getRoomRequest: + getRoomRequest.TriggerSuccess(rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); + break; + + case GetBeatmapSetRequest getBeatmapSetRequest: + var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type); + onlineReq.Success += res => getBeatmapSetRequest.TriggerSuccess(res); + onlineReq.Failure += e => getBeatmapSetRequest.TriggerFailure(e); + + // Get the online API from the game's dependencies. + game.Dependencies.Get().Queue(onlineReq); + break; + + case CreateRoomScoreRequest createRoomScoreRequest: + createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); + break; + + case SubmitRoomScoreRequest submitRoomScoreRequest: + submitRoomScoreRequest.TriggerSuccess(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.S, + MaxCombo = 1000, + TotalScore = 1000000, + User = api.LocalUser.Value, + Statistics = new Dictionary() + }); + break; + } + }; + } + + public new void ClearRooms() => base.ClearRooms(); + + public new void Schedule(Action action) => base.Schedule(action); + } +}