diff --git a/.idea/.idea.osu.Desktop/.idea/misc.xml b/.idea/.idea.osu.Desktop/.idea/misc.xml index 1d8c84d0af..4e1d56f4dd 100644 --- a/.idea/.idea.osu.Desktop/.idea/misc.xml +++ b/.idea/.idea.osu.Desktop/.idea/misc.xml @@ -1,5 +1,10 @@ + + + diff --git a/osu.Android.props b/osu.Android.props index 331c4db01f..ec638f7736 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs index 77b402ad3c..5c04ac88a7 100644 --- a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs +++ b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs @@ -26,6 +26,12 @@ namespace osu.Game.Tests.Gameplay Dependencies.Cache(localConfig = new OsuConfigManager(LocalStorage)); } + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset audio offset", () => localConfig.SetValue(OsuSetting.AudioOffset, 0.0)); + } + [Test] public void TestStartThenElapsedTime() { @@ -36,7 +42,7 @@ namespace osu.Game.Tests.Gameplay var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); working.LoadTrack(); - Add(gameplayClockContainer = new MasterGameplayClockContainer(working, 0)); + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); }); AddStep("start clock", () => gameplayClockContainer.Start()); @@ -53,7 +59,7 @@ namespace osu.Game.Tests.Gameplay var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); working.LoadTrack(); - Add(gameplayClockContainer = new MasterGameplayClockContainer(working, 0)); + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); }); AddStep("start clock", () => gameplayClockContainer.Start()); @@ -73,26 +79,29 @@ namespace osu.Game.Tests.Gameplay public void TestSeekPerformsInGameplayTime( [Values(1.0, 0.5, 2.0)] double clockRate, [Values(0.0, 200.0, -200.0)] double userOffset, - [Values(false, true)] bool whileStopped) + [Values(false, true)] bool whileStopped, + [Values(false, true)] bool setAudioOffsetBeforeConstruction) { ClockBackedTestWorkingBeatmap working = null; GameplayClockContainer gameplayClockContainer = null; + if (setAudioOffsetBeforeConstruction) + AddStep($"preset audio offset to {userOffset}", () => localConfig.SetValue(OsuSetting.AudioOffset, userOffset)); + AddStep("create container", () => { working = new ClockBackedTestWorkingBeatmap(new OsuRuleset().RulesetInfo, new FramedClock(new ManualClock()), Audio); working.LoadTrack(); - Add(gameplayClockContainer = new MasterGameplayClockContainer(working, 0)); + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); - if (whileStopped) - gameplayClockContainer.Stop(); - - gameplayClockContainer.Reset(); + gameplayClockContainer.Reset(startClock: !whileStopped); }); AddStep($"set clock rate to {clockRate}", () => working.Track.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(clockRate))); - AddStep($"set audio offset to {userOffset}", () => localConfig.SetValue(OsuSetting.AudioOffset, userOffset)); + + if (!setAudioOffsetBeforeConstruction) + AddStep($"set audio offset to {userOffset}", () => localConfig.SetValue(OsuSetting.AudioOffset, userOffset)); AddStep("seek to 2500", () => gameplayClockContainer.Seek(2500)); AddAssert("gameplay clock time = 2500", () => Precision.AlmostEquals(gameplayClockContainer.CurrentTime, 2500, 10f)); diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 76ec35d87d..e0a497cf24 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -88,7 +88,7 @@ namespace osu.Game.Tests.Gameplay [Test] public void TestSampleHasLifetimeEndWithInitialClockTime() { - GameplayClockContainer gameplayContainer = null; + MasterGameplayClockContainer gameplayContainer = null; DrawableStoryboardSample sample = null; AddStep("create container", () => @@ -96,8 +96,11 @@ namespace osu.Game.Tests.Gameplay var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); working.LoadTrack(); - Add(gameplayContainer = new MasterGameplayClockContainer(working, 1000, true) + const double start_time = 1000; + + Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time) { + StartTime = start_time, IsPaused = { Value = true }, Child = new FrameStabilityContainer { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index 744227c55e..83d7d769df 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -56,10 +56,11 @@ namespace osu.Game.Tests.Visual.Gameplay private double lastFrequency = double.MaxValue; - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); + base.UpdateAfterChildren(); + // This must be done in UpdateAfterChildren to allow the gameplay clock to have updated before checking values. double freq = Beatmap.Value.Track.AggregateFrequency.Value; FrequencyIncreased |= freq > lastFrequency; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 5a1fc1b1e5..b90bd93002 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -1,12 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu; @@ -36,10 +34,10 @@ namespace osu.Game.Tests.Visual.Gameplay BeatmapInfo = { AudioLeadIn = leadIn } }); - AddAssert($"first frame is {expectedStartTime}", () => + AddStep("check first frame time", () => { - Debug.Assert(player.FirstFrameClockTime != null); - return Precision.AlmostEquals(player.FirstFrameClockTime.Value, expectedStartTime, lenience_ms); + Assert.That(player.FirstFrameClockTime, Is.Not.Null); + Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); }); } @@ -59,10 +57,10 @@ namespace osu.Game.Tests.Visual.Gameplay loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); - AddAssert($"first frame is {expectedStartTime}", () => + AddStep("check first frame time", () => { - Debug.Assert(player.FirstFrameClockTime != null); - return Precision.AlmostEquals(player.FirstFrameClockTime.Value, expectedStartTime, lenience_ms); + Assert.That(player.FirstFrameClockTime, Is.Not.Null); + Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); }); } @@ -97,10 +95,10 @@ namespace osu.Game.Tests.Visual.Gameplay loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); - AddAssert($"first frame is {expectedStartTime}", () => + AddStep("check first frame time", () => { - Debug.Assert(player.FirstFrameClockTime != null); - return Precision.AlmostEquals(player.FirstFrameClockTime.Value, expectedStartTime, lenience_ms); + Assert.That(player.FirstFrameClockTime, Is.Not.Null); + Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 5c2fd26857..ff6c02c4e5 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -3,80 +3,155 @@ using System; using System.Linq; +using Moq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Platform; +using osu.Framework.Logging; using osu.Framework.Testing; using osu.Framework.Utils; -using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; -using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchStartControl : MultiplayerTestScene + public class TestSceneMatchStartControl : OsuManualInputManagerTestScene { + private readonly Mock multiplayerClient = new Mock(); + private readonly Mock availabilityTracker = new Mock(); + + private readonly Bindable beatmapAvailability = new Bindable(); + private readonly Bindable room = new Bindable(); + + private MultiplayerRoom multiplayerRoom; + private MultiplayerRoomUser localUser; + private OngoingOperationTracker ongoingOperationTracker; + + private PopoverContainer content; private MatchStartControl control; - private BeatmapSetInfo importedSet; - private readonly Bindable selectedItem = new Bindable(); + private OsuButton readyButton => control.ChildrenOfType().Single(); - private BeatmapManager beatmaps; - private RulesetStore rulesets; + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) { Model = { BindTarget = room } }; [BackgroundDependencyLoader] - private void load(GameHost host, AudioManager audio) + private void load() { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(Realm); + Dependencies.CacheAs(multiplayerClient.Object); + Dependencies.CacheAs(ongoingOperationTracker = new OngoingOperationTracker()); + Dependencies.CacheAs(availabilityTracker.Object); + + availabilityTracker.SetupGet(a => a.Availability).Returns(beatmapAvailability); + + multiplayerClient.SetupGet(m => m.LocalUser).Returns(() => localUser); + multiplayerClient.SetupGet(m => m.Room).Returns(() => multiplayerRoom); + + // By default, the local user is to be the host. + multiplayerClient.SetupGet(m => m.IsHost).Returns(() => ReferenceEquals(multiplayerRoom.Host, localUser)); + + // Assume all state changes are accepted by the server. + multiplayerClient.Setup(m => m.ChangeState(It.IsAny())) + .Callback((MultiplayerUserState r) => + { + Logger.Log($"Changing local user state from {localUser.State} to {r}"); + localUser.State = r; + raiseRoomUpdated(); + }); + + multiplayerClient.Setup(m => m.StartMatch()) + .Callback(() => + { + multiplayerClient.Raise(m => m.LoadRequested -= null); + + // immediately "end" gameplay, as we don't care about that part of the process. + changeUserState(localUser.UserID, MultiplayerUserState.Idle); + }); + + multiplayerClient.Setup(m => m.SendMatchRequest(It.IsAny())) + .Callback((MatchUserRequest request) => + { + switch (request) + { + case StartMatchCountdownRequest countdownStart: + setRoomCountdown(countdownStart.Duration); + break; + + case StopCountdownRequest _: + multiplayerRoom.Countdown = null; + raiseRoomUpdated(); + break; + } + }); + + Children = new Drawable[] + { + ongoingOperationTracker, + content = new PopoverContainer { RelativeSizeAxes = Axes.Both } + }; } - [SetUp] - public new void Setup() => Schedule(() => + [SetUpSteps] + public void SetUpSteps() { - AvailabilityTracker.SelectedItem.BindTo(selectedItem); - - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); - Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); - - selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) + AddStep("reset state", () => { - RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID - }; + multiplayerClient.Invocations.Clear(); - Child = new PopoverContainer + beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable(); + + var playlistItem = new PlaylistItem(Beatmap.Value.BeatmapInfo) + { + RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID + }; + + room.Value = new Room + { + Playlist = { playlistItem }, + CurrentPlaylistItem = { Value = playlistItem } + }; + + localUser = new MultiplayerRoomUser(API.LocalUser.Value.Id) { User = API.LocalUser.Value }; + + multiplayerRoom = new MultiplayerRoom(0) + { + Playlist = + { + new MultiplayerPlaylistItem(playlistItem), + }, + Users = { localUser }, + Host = localUser, + }; + }); + + AddStep("create control", () => { - RelativeSizeAxes = Axes.Both, - Child = control = new MatchStartControl + content.Child = control = new MatchStartControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(250, 50), - } - }; - }); + }; + }); + } [Test] public void TestStartWithCountdown() { ClickButtonWhenEnabled(); AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); AddStep("click the first countdown button", () => { @@ -85,8 +160,12 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); - AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown()); - AddUntilStep("match started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.WaitingForLoad); + AddStep("check request received", () => + { + multiplayerClient.Verify(m => m.SendMatchRequest(It.Is(req => + req.Duration == TimeSpan.FromSeconds(10) + )), Times.Once); + }); } [Test] @@ -94,6 +173,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { ClickButtonWhenEnabled(); AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); AddStep("click the first countdown button", () => { @@ -102,6 +182,13 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); + AddStep("check request received", () => + { + multiplayerClient.Verify(m => m.SendMatchRequest(It.Is(req => + req.Duration == TimeSpan.FromSeconds(10) + )), Times.Once); + }); + ClickButtonWhenEnabled(); AddStep("click the cancel button", () => { @@ -110,41 +197,39 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); - AddStep("finish countdown", () => MultiplayerClient.SkipToEndOfCountdown()); - AddUntilStep("match not started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + AddStep("check request received", () => + { + multiplayerClient.Verify(m => m.SendMatchRequest(It.IsAny()), Times.Once); + }); } [Test] public void TestReadyAndUnReadyDuringCountdown() { - AddStep("add second user as host", () => - { - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - MultiplayerClient.TransferHost(2); - }); + AddStep("add second user as host", () => addUser(new APIUser { Id = 2, Username = "Another user" }, true)); - AddStep("start with countdown", () => MultiplayerClient.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(2) }).WaitSafely()); + AddStep("start countdown", () => setRoomCountdown(TimeSpan.FromMinutes(1))); ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); ClickButtonWhenEnabled(); - AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + checkLocalUserState(MultiplayerUserState.Idle); } [Test] public void TestCountdownWhileSpectating() { - AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); - AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + AddStep("set spectating", () => changeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + checkLocalUserState(MultiplayerUserState.Spectating); AddAssert("countdown button is visible", () => this.ChildrenOfType().Single().IsPresent); AddAssert("countdown button enabled", () => this.ChildrenOfType().Single().Enabled.Value); - AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" })); + AddStep("add second user", () => addUser(new APIUser { Id = 2, Username = "Another user" })); AddAssert("countdown button enabled", () => this.ChildrenOfType().Single().Enabled.Value); - AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready)); + AddStep("set second user ready", () => changeUserState(2, MultiplayerUserState.Ready)); AddAssert("countdown button enabled", () => this.ChildrenOfType().Single().Enabled.Value); } @@ -153,60 +238,54 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add second user as host", () => { - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - MultiplayerClient.TransferHost(2); + addUser(new APIUser { Id = 2, Username = "Another user" }, true); }); - AddStep("start countdown", () => MultiplayerClient.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely()); - AddUntilStep("countdown started", () => MultiplayerClient.Room?.Countdown != null); + AddStep("start countdown", () => multiplayerClient.Object.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely()); + AddUntilStep("countdown started", () => multiplayerRoom.Countdown != null); - AddStep("transfer host to local user", () => MultiplayerClient.TransferHost(API.LocalUser.Value.OnlineID)); - AddUntilStep("local user is host", () => MultiplayerClient.Room?.Host?.Equals(MultiplayerClient.LocalUser) == true); + AddStep("transfer host to local user", () => transferHost(localUser)); + AddUntilStep("local user is host", () => multiplayerRoom.Host?.Equals(multiplayerClient.Object.LocalUser) == true); ClickButtonWhenEnabled(); - AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); - AddAssert("countdown still active", () => MultiplayerClient.Room?.Countdown != null); + checkLocalUserState(MultiplayerUserState.Ready); + AddAssert("countdown still active", () => multiplayerRoom.Countdown != null); } [Test] - public void TestCountdownButtonVisibilityWithAutoStartEnablement() + public void TestCountdownButtonVisibilityWithAutoStart() { ClickButtonWhenEnabled(); - AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); AddUntilStep("countdown button visible", () => this.ChildrenOfType().Single().IsPresent); - AddStep("enable auto start", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) }).WaitSafely()); + AddStep("enable auto start", () => changeRoomSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) })); ClickButtonWhenEnabled(); - AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); AddUntilStep("countdown button not visible", () => !this.ChildrenOfType().Single().IsPresent); } [Test] public void TestClickingReadyButtonUnReadiesDuringAutoStart() { - AddStep("enable auto start", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) }).WaitSafely()); + AddStep("enable auto start", () => changeRoomSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) })); ClickButtonWhenEnabled(); - AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); ClickButtonWhenEnabled(); - AddUntilStep("local user became idle", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Idle); + checkLocalUserState(MultiplayerUserState.Idle); } [Test] public void TestDeletedBeatmapDisableReady() { - OsuButton readyButton = null; + AddUntilStep("ready button enabled", () => readyButton.Enabled.Value); - AddUntilStep("ensure ready button enabled", () => - { - readyButton = control.ChildrenOfType().Single(); - return readyButton.Enabled.Value; - }); - - AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + AddStep("mark beatmap not available", () => beatmapAvailability.Value = BeatmapAvailability.NotDownloaded()); AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value); - AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet)); + + AddStep("mark beatmap available", () => beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable()); AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value); } @@ -215,31 +294,25 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add second user as host", () => { - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - MultiplayerClient.TransferHost(2); + addUser(new APIUser { Id = 2, Username = "Another user" }, true); }); ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); ClickButtonWhenEnabled(); - AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + checkLocalUserState(MultiplayerUserState.Idle); } [TestCase(true)] [TestCase(false)] public void TestToggleStateWhenHost(bool allReady) { - AddStep("setup", () => - { - MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); - - if (!allReady) - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - }); + if (!allReady) + AddStep("add other user", () => addUser(new APIUser { Id = 2, Username = "Another user" })); ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); verifyGameplayStartFlow(); } @@ -249,12 +322,12 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add host", () => { - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); - MultiplayerClient.TransferHost(2); + addUser(new APIUser { Id = 2, Username = "Another user" }, true); }); ClickButtonWhenEnabled(); - AddStep("make user host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0)); + + AddStep("make local user host", () => transferHost(localUser)); verifyGameplayStartFlow(); } @@ -264,18 +337,17 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("setup", () => { - MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); - MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + addUser(new APIUser { Id = 2, Username = "Another user" }); }); ClickButtonWhenEnabled(); - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); - AddStep("transfer host", () => MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[1].UserID ?? 0)); + AddStep("transfer host", () => transferHost(multiplayerRoom.Users[1])); ClickButtonWhenEnabled(); - AddUntilStep("user is idle (match not started)", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); - AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value); + checkLocalUserState(MultiplayerUserState.Idle); + AddUntilStep("ready button enabled", () => readyButton.Enabled.Value); } [TestCase(true)] @@ -283,44 +355,83 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestManyUsersChangingState(bool isHost) { const int users = 10; - AddStep("setup", () => - { - MultiplayerClient.TransferHost(MultiplayerClient.Room?.Users[0].UserID ?? 0); - for (int i = 0; i < users; i++) - MultiplayerClient.AddUser(new APIUser { Id = i, Username = "Another user" }); - }); - if (!isHost) - AddStep("transfer host", () => MultiplayerClient.TransferHost(2)); + AddStep("add many users", () => + { + for (int i = 0; i < users; i++) + addUser(new APIUser { Id = i, Username = "Another user" }, !isHost && i == 2); + }); ClickButtonWhenEnabled(); AddRepeatStep("change user ready state", () => { - MultiplayerClient.ChangeUserState(RNG.Next(0, users), RNG.NextBool() ? MultiplayerUserState.Ready : MultiplayerUserState.Idle); + changeUserState(RNG.Next(0, users), RNG.NextBool() ? MultiplayerUserState.Ready : MultiplayerUserState.Idle); }, 20); AddRepeatStep("ready all users", () => { - var nextUnready = MultiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); + var nextUnready = multiplayerRoom.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); if (nextUnready != null) - MultiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); + changeUserState(nextUnready.UserID, MultiplayerUserState.Ready); }, users); } private void verifyGameplayStartFlow() { - AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + checkLocalUserState(MultiplayerUserState.Ready); ClickButtonWhenEnabled(); - AddUntilStep("user waiting for load", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); - AddStep("finish gameplay", () => - { - MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded); - MultiplayerClient.ChangeUserState(MultiplayerClient.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay); - }); - - AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value); + AddStep("check start request received", () => multiplayerClient.Verify(m => m.StartMatch(), Times.Once)); } + + private void checkLocalUserState(MultiplayerUserState state) => + AddUntilStep($"local user is {state}", () => localUser.State == state); + + private void setRoomCountdown(TimeSpan duration) + { + multiplayerRoom.Countdown = new MatchStartCountdown { TimeRemaining = duration }; + raiseRoomUpdated(); + } + + private void changeUserState(int userId, MultiplayerUserState newState) + { + multiplayerRoom.Users.Single(u => u.UserID == userId).State = newState; + raiseRoomUpdated(); + } + + private void addUser(APIUser user, bool asHost = false) + { + var multiplayerRoomUser = new MultiplayerRoomUser(user.Id) { User = user }; + + multiplayerRoom.Users.Add(multiplayerRoomUser); + + if (asHost) + transferHost(multiplayerRoomUser); + + raiseRoomUpdated(); + } + + private void transferHost(MultiplayerRoomUser user) + { + multiplayerRoom.Host = user; + raiseRoomUpdated(); + } + + private void changeRoomSettings(MultiplayerRoomSettings settings) + { + multiplayerRoom.Settings = settings; + + // Changing settings should reset all user ready statuses. + foreach (var user in multiplayerRoom.Users) + { + if (user.State == MultiplayerUserState.Ready) + user.State = MultiplayerUserState.Idle; + } + + raiseRoomUpdated(); + } + + private void raiseRoomUpdated() => multiplayerClient.Raise(m => m.RoomUpdated -= null); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index e5e3fecd06..703b526e8c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -464,16 +464,16 @@ namespace osu.Game.Tests.Visual.Multiplayer private class TestMultiSpectatorScreen : MultiSpectatorScreen { - private readonly double? gameplayStartTime; + private readonly double? startTime; - public TestMultiSpectatorScreen(Room room, MultiplayerRoomUser[] users, double? gameplayStartTime = null) + public TestMultiSpectatorScreen(Room room, MultiplayerRoomUser[] users, double? startTime = null) : base(room, users) { - this.gameplayStartTime = gameplayStartTime; + this.startTime = startTime; } protected override MasterGameplayClockContainer CreateMasterGameplayClockContainer(WorkingBeatmap beatmap) - => new MasterGameplayClockContainer(beatmap, gameplayStartTime ?? 0, gameplayStartTime.HasValue); + => new MasterGameplayClockContainer(beatmap, 0) { StartTime = startTime ?? 0 }; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index d0765fc4b3..6a69917fb4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -495,17 +495,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); - AddAssert("Mods match current item", () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); + AddAssert("Mods match current item", + () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); AddStep("Switch required mods", () => ((MultiplayerMatchSongSelect)multiplayerComponents.MultiplayerScreen.CurrentSubScreen).Mods.Value = new Mod[] { new OsuModDoubleTime() }); - AddAssert("Mods don't match current item", () => !SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); + AddAssert("Mods don't match current item", + () => !SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); - AddAssert("Mods match current item", () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); + AddAssert("Mods match current item", + () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); } [Test] @@ -665,6 +668,41 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for results", () => multiplayerComponents.CurrentScreen is ResultsScreen); } + [Test] + public void TestGameplayDoesntStartWithNonLoadedUser() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + } + } + }); + + pressReadyButton(); + + AddStep("join other user and ready", () => + { + multiplayerClient.AddUser(new APIUser { Id = 1234 }); + multiplayerClient.ChangeUserState(1234, MultiplayerUserState.Ready); + }); + + AddStep("start match", () => + { + multiplayerClient.StartMatch(); + }); + + AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player); + + AddWaitStep("wait some", 20); + + AddAssert("ensure gameplay hasn't started", () => this.ChildrenOfType().SingleOrDefault()?.IsRunning == false); + } + [Test] public void TestRoomSettingsReQueriedWhenJoiningRoom() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs new file mode 100644 index 0000000000..5d554719a5 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs @@ -0,0 +1,114 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneScalingContainer : OsuTestScene + { + private OsuConfigManager osuConfigManager { get; set; } + + private ScalingContainer scaling1; + private ScalingContainer scaling2; + private Box scaleTarget; + + [BackgroundDependencyLoader] + private void load() + { + osuConfigManager = new OsuConfigManager(LocalStorage); + + Dependencies.CacheAs(osuConfigManager); + + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + scaling1 = new ScalingContainer(ScalingMode.Everything) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f), + Children = new Drawable[] + { + scaling2 = new ScalingContainer(ScalingMode.Everything) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f), + Children = new Drawable[] + { + new Box + { + Colour = Color4.Purple, + RelativeSizeAxes = Axes.Both, + }, + scaleTarget = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White, + Size = new Vector2(100), + }, + } + } + } + } + } + }, + }; + } + + [Test] + public void TestScaling() + { + AddStep("adjust scale", () => osuConfigManager.SetValue(OsuSetting.UIScale, 2f)); + + checkForCorrectness(); + + AddStep("adjust scale", () => osuConfigManager.SetValue(OsuSetting.UIScale, 0.5f)); + + checkForCorrectness(); + } + + private void checkForCorrectness() + { + Quad? scaling1LastQuad = null; + Quad? scaling2LastQuad = null; + Quad? scalingTargetLastQuad = null; + + AddUntilStep("ensure dimensions don't change", () => + { + if (scaling1LastQuad.HasValue && scaling2LastQuad.HasValue) + { + // check inter-frame changes to make sure they match expectations. + Assert.That(scaling1.ScreenSpaceDrawQuad.AlmostEquals(scaling1LastQuad.Value), Is.True); + Assert.That(scaling2.ScreenSpaceDrawQuad.AlmostEquals(scaling2LastQuad.Value), Is.True); + } + + scaling1LastQuad = scaling1.ScreenSpaceDrawQuad; + scaling2LastQuad = scaling2.ScreenSpaceDrawQuad; + + // wait for scaling to stop. + bool scalingFinished = scalingTargetLastQuad.HasValue && scaleTarget.ScreenSpaceDrawQuad.AlmostEquals(scalingTargetLastQuad.Value); + + scalingTargetLastQuad = scaleTarget.ScreenSpaceDrawQuad; + + return scalingFinished; + }); + } + } +} diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 7d28208157..bc810ee35e 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -26,6 +26,11 @@ namespace osu.Game.Beatmaps { private readonly WeakList workingCache = new WeakList(); + /// + /// Beatmap files may specify this filename to denote that they don't have an audio track. + /// + private const string virtual_track_filename = @"virtual"; + /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. /// @@ -40,7 +45,8 @@ namespace osu.Game.Beatmaps [CanBeNull] private readonly GameHost host; - public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, GameHost host = null) + public WorkingBeatmapCache(ITrackStore trackStore, AudioManager audioManager, IResourceStore resources, IResourceStore files, WorkingBeatmap defaultBeatmap = null, + GameHost host = null) { DefaultBeatmap = defaultBeatmap; @@ -157,6 +163,9 @@ namespace osu.Game.Beatmaps if (string.IsNullOrEmpty(Metadata?.AudioFile)) return null; + if (Metadata.AudioFile == virtual_track_filename) + return null; + try { return resources.Tracks.Get(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); @@ -173,6 +182,9 @@ namespace osu.Game.Beatmaps if (string.IsNullOrEmpty(Metadata?.AudioFile)) return null; + if (Metadata.AudioFile == virtual_track_filename) + return null; + try { var trackData = GetStream(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 58d18e1b21..ca8b6f388f 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -21,6 +21,8 @@ namespace osu.Game.Graphics.Containers /// public class ScalingContainer : Container { + private const float duration = 500; + private Bindable sizeX; private Bindable sizeY; private Bindable posX; @@ -82,6 +84,8 @@ namespace osu.Game.Graphics.Containers private readonly bool applyUIScale; private Bindable uiScale; + private float currentScale = 1; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public ScalingDrawSizePreservingFillContainer(bool applyUIScale) @@ -95,14 +99,16 @@ namespace osu.Game.Graphics.Containers if (applyUIScale) { uiScale = osuConfig.GetBindable(OsuSetting.UIScale); - uiScale.BindValueChanged(scaleChanged, true); + uiScale.BindValueChanged(args => this.TransformTo(nameof(currentScale), args.NewValue, duration, Easing.OutQuart), true); } } - private void scaleChanged(ValueChangedEvent args) + protected override void Update() { - this.ScaleTo(new Vector2(args.NewValue), 500, Easing.Out); - this.ResizeTo(new Vector2(1 / args.NewValue), 500, Easing.Out); + Scale = new Vector2(currentScale); + Size = new Vector2(1 / currentScale); + + base.Update(); } } @@ -140,8 +146,6 @@ namespace osu.Game.Graphics.Containers private void updateSize() { - const float duration = 500; - if (targetMode == ScalingMode.Everything) { // the top level scaling container manages the background to be displayed while scaling. diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 4c10949225..967220abbf 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -67,7 +67,7 @@ namespace osu.Game.Online.Multiplayer /// /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. /// - public event Action? LoadRequested; + public virtual event Action? LoadRequested; /// /// Invoked when the multiplayer server requests gameplay to be started. @@ -114,12 +114,12 @@ namespace osu.Game.Online.Multiplayer /// /// The corresponding to the local player, if available. /// - public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); + public virtual MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); /// /// Whether the is the host in . /// - public bool IsHost + public virtual bool IsHost { get { diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 07506ba1f0..4ca6d79b19 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -25,7 +25,7 @@ namespace osu.Game.Online.Rooms /// This differs from a regular download tracking composite as this accounts for the /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. /// - public sealed class OnlinePlayBeatmapAvailabilityTracker : CompositeDrawable + public class OnlinePlayBeatmapAvailabilityTracker : CompositeDrawable { public readonly IBindable SelectedItem = new Bindable(); @@ -41,7 +41,7 @@ namespace osu.Game.Online.Rooms /// /// The availability state of the currently selected playlist item. /// - public IBindable Availability => availability; + public virtual IBindable Availability => availability; private readonly Bindable availability = new Bindable(BeatmapAvailability.NotDownloaded()); diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index 59894cbcae..6e1558f7d7 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -39,6 +39,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { LabelText = UserInterfaceStrings.HoldToConfirmActivationTime, Current = config.GetBindable(OsuSetting.UIHoldActivationDelay), + Keywords = new[] { @"delay" }, KeyboardStep = 50 }, }; diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index 2539c32806..6645d20dbe 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -23,7 +23,9 @@ namespace osu.Game.Overlays.Settings private IBindable selectedSection; - private OsuSpriteText header; + private Box dim; + + private const float inactive_alpha = 0.8f; public abstract Drawable CreateIcon(); public abstract LocalisableString Header { get; } @@ -78,25 +80,40 @@ namespace osu.Game.Overlays.Settings }, new Container { - Padding = new MarginPadding - { - Top = 28, - Bottom = 40, - }, + Padding = new MarginPadding { Top = border_size }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] { - header = new OsuSpriteText + new Container { - Font = OsuFont.TorusAlternate.With(size: header_size), - Text = Header, - Margin = new MarginPadding + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { - Horizontal = SettingsPanel.CONTENT_MARGINS + Top = 24, + Bottom = 40, + }, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: header_size), + Text = Header, + Margin = new MarginPadding + { + Horizontal = SettingsPanel.CONTENT_MARGINS + } + }, + FlowContent } }, - FlowContent + dim = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + Alpha = inactive_alpha, + }, } }, }); @@ -134,17 +151,14 @@ namespace osu.Game.Overlays.Settings private void updateContentFade() { - float contentFade = 1; - float headerFade = 1; + float dimFade = 0; if (!isCurrentSection) { - contentFade = 0.25f; - headerFade = IsHovered ? 0.5f : 0.25f; + dimFade = IsHovered ? 0.5f : inactive_alpha; } - header.FadeTo(headerFade, 500, Easing.OutQuint); - FlowContent.FadeTo(contentFade, 500, Easing.OutQuint); + dim.FadeTo(dimFade, 300, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index b11b6fde27..d5b36713f3 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -197,7 +197,7 @@ namespace osu.Game.Overlays ContentContainer.Margin = new MarginPadding { Left = Sidebar?.DrawWidth ?? 0 }; } - private const double fade_in_duration = 1000; + private const double fade_in_duration = 500; private void loadSections() { diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 29559f5036..be1105e7ff 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -133,6 +133,11 @@ namespace osu.Game.Rulesets.UI p.NewResult += (_, r) => NewResult?.Invoke(r); p.RevertResult += (_, r) => RevertResult?.Invoke(r); })); + } + + protected override void LoadComplete() + { + base.LoadComplete(); IsPaused.ValueChanged += paused => { diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index f49603c754..d9e19df350 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -25,7 +25,7 @@ namespace osu.Game.Screens.Edit.GameplayTest } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) - => new MasterGameplayClockContainer(beatmap, editorState.Time, true); + => new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time }; protected override void LoadComplete() { diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index 88bea43b23..c07ada9419 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -185,8 +185,7 @@ namespace osu.Game.Screens.Menu private void load(AudioManager audio) { sampleHover = audio.Samples.Get(@"Menu/button-hover"); - if (!string.IsNullOrEmpty(sampleName)) - sampleClick = audio.Samples.Get($@"Menu/{sampleName}"); + sampleClick = audio.Samples.Get(!string.IsNullOrEmpty(sampleName) ? $@"Menu/{sampleName}" : @"UI/button-select"); } protected override bool OnMouseDown(MouseDownEvent e) diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index c82efe2d32..1d3aef0653 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -283,9 +283,15 @@ namespace osu.Game.Screens.Menu this.Delay(early_activation).Schedule(() => { if (beatIndex % timingPoint.TimeSignature.Numerator == 0) - sampleDownbeat.Play(); + { + sampleDownbeat?.Play(); + } else - sampleBeat.Play(); + { + var channel = sampleBeat.GetChannel(); + channel.Frequency.Value = 0.95 + RNG.NextDouble(0.1); + channel.Play(); + } }); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs index 1a5231e602..de23b4fef7 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs @@ -17,8 +17,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Bindable WaitingOnFrames { get; } /// - /// Whether this clock is resynchronising to the master clock. + /// Whether this clock is behind the master clock and running at a higher rate to catch up to it. /// + /// + /// Of note, this will be false if this clock is *ahead* of the master clock. + /// bool IsCatchingUp { get; set; } /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 615bd41f3f..29afaf00d8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -55,12 +55,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public SpectatorGameplayClockContainer([NotNull] IClock sourceClock) : base(sourceClock) { - // the container should initially be in a stopped state until the catch-up clock is started by the sync manager. - Stop(); } protected override void Update() { + // The SourceClock here is always a CatchUpSpectatorPlayerClock. // The player clock's running state is controlled externally, but the local pausing state needs to be updated to stop gameplay. if (SourceClock.IsRunning) Start(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 6747b8fc66..2d03276fe5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -164,7 +164,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate base.LoadComplete(); masterClockContainer.Reset(); - masterClockContainer.Stop(); syncManager.ReadyToStart += onReadyToStart; syncManager.MasterState.BindValueChanged(onMasterStateChanged, true); @@ -198,8 +197,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate .DefaultIfEmpty(0) .Min(); - masterClockContainer.Seek(startTime); - masterClockContainer.Start(); + masterClockContainer.StartTime = startTime; + masterClockContainer.Reset(true); // Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it. canStartMasterClock = true; diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 0fd524f976..721abc66f8 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Play /// /// Whether gameplay is paused. /// - public readonly BindableBool IsPaused = new BindableBool(); + public readonly BindableBool IsPaused = new BindableBool(true); /// /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. @@ -41,6 +41,15 @@ namespace osu.Game.Screens.Play /// public event Action OnSeek; + /// + /// The time from which the clock should start. Will be seeked to on calling . + /// + /// + /// If not set, a value of zero will be used. + /// Importantly, the value will be inferred from the current ruleset in unless specified. + /// + public double? StartTime { get; set; } + /// /// Creates a new . /// @@ -106,16 +115,17 @@ namespace osu.Game.Screens.Play /// /// Resets this and the source to an initial state ready for gameplay. /// - public virtual void Reset() + /// Whether to start the clock immediately, if not already started. + public void Reset(bool startClock = false) { - ensureSourceClockSet(); - Seek(0); - // Manually stop the source in order to not affect the IsPaused state. AdjustableSource.Stop(); - if (!IsPaused.Value) + if (!IsPaused.Value || startClock) Start(); + + ensureSourceClockSet(); + Seek(StartTime ?? 0); } /// diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index af58e9d910..ea43fb1546 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -46,36 +46,36 @@ namespace osu.Game.Screens.Play private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; - private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); + private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock. private readonly WorkingBeatmap beatmap; - private readonly double gameplayStartTime; - private readonly bool startAtGameplayStart; - private readonly double firstHitObjectTime; private HardwareCorrectionOffsetClock userGlobalOffsetClock; private HardwareCorrectionOffsetClock userBeatmapOffsetClock; private HardwareCorrectionOffsetClock platformOffsetClock; private MasterGameplayClock masterGameplayClock; private Bindable userAudioOffset; - private double startOffset; private IDisposable beatmapOffsetSubscription; + private readonly double skipTargetTime; + [Resolved] private RealmAccess realm { get; set; } [Resolved] private OsuConfigManager config { get; set; } - public MasterGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) + /// + /// Create a new master gameplay clock container. + /// + /// The beatmap to be used for time and metadata references. + /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. + public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime) : base(beatmap.Track) { this.beatmap = beatmap; - this.gameplayStartTime = gameplayStartTime; - this.startAtGameplayStart = startAtGameplayStart; - - firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; + this.skipTargetTime = skipTargetTime; } protected override void LoadComplete() @@ -90,41 +90,67 @@ namespace osu.Game.Screens.Play settings => settings.Offset, val => userBeatmapOffsetClock.Offset = val); - // sane default provided by ruleset. - startOffset = gameplayStartTime; + // Reset may have been called externally before LoadComplete. + // If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here. + bool isStarted = !IsPaused.Value; - if (!startAtGameplayStart) - { - startOffset = Math.Min(0, startOffset); + // If a custom start time was not specified, calculate the best value to use. + StartTime ??= findEarliestStartTime(); - // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. - // this is commonly used to display an intro before the audio track start. - double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; - if (firstStoryboardEvent != null) - startOffset = Math.Min(startOffset, firstStoryboardEvent.Value); + Reset(startClock: isStarted); + } - // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. - // this is not available as an option in the live editor but can still be applied via .osu editing. - if (beatmap.BeatmapInfo.AudioLeadIn > 0) - startOffset = Math.Min(startOffset, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); - } + private double findEarliestStartTime() + { + // here we are trying to find the time to start playback from the "zero" point. + // generally this is either zero, or some point earlier than zero in the case of storyboards, lead-ins etc. - Seek(startOffset); + // start with the originally provided latest time (if before zero). + double time = Math.Min(0, skipTargetTime); + + // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. + // this is commonly used to display an intro before the audio track start. + double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; + if (firstStoryboardEvent != null) + time = Math.Min(time, firstStoryboardEvent.Value); + + // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. + // this is not available as an option in the live editor but can still be applied via .osu editing. + double firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; + if (beatmap.BeatmapInfo.AudioLeadIn > 0) + time = Math.Min(time, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); + + return time; } protected override void OnIsPausedChanged(ValueChangedEvent isPaused) { - // The source is stopped by a frequency fade first. - if (isPaused.NewValue) + if (IsLoaded) { - this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => + // During normal operation, the source is stopped after performing a frequency ramp. + if (isPaused.NewValue) { - if (IsPaused.Value == isPaused.NewValue) - AdjustableSource.Stop(); - }); + this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => + { + if (IsPaused.Value == isPaused.NewValue) + AdjustableSource.Stop(); + }); + } + else + this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); } else - this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); + { + if (isPaused.NewValue) + AdjustableSource.Stop(); + + // If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations. + pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1; + + // We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment. + // Without doing this, an initial seek may be performed with the wrong offset. + GameplayClock.UnderlyingClock.ProcessFrame(); + } } public override void Start() @@ -152,10 +178,10 @@ namespace osu.Game.Screens.Play /// public void Skip() { - if (GameplayClock.CurrentTime > gameplayStartTime - MINIMUM_SKIP_TIME) + if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME) return; - double skipTarget = gameplayStartTime - MINIMUM_SKIP_TIME; + double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME; if (GameplayClock.CurrentTime < 0 && skipTarget > 6000) // double skip exception for storyboards with very long intros @@ -164,12 +190,6 @@ namespace osu.Game.Screens.Play Seek(skipTarget); } - public override void Reset() - { - base.Reset(); - Seek(startOffset); - } - protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) { // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. @@ -278,7 +298,6 @@ namespace osu.Game.Screens.Play private class MasterGameplayClock : GameplayClock { public readonly List> MutableNonGameplayAdjustments = new List>(); - public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; public MasterGameplayClock(FramedOffsetClock underlyingClock) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 73bdeb5783..f99b07c313 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -607,30 +607,25 @@ namespace osu.Game.Screens.Play private ScheduledDelegate frameStablePlaybackResetDelegate; /// - /// Seeks to a specific time in gameplay, bypassing frame stability. + /// Specify and seek to a custom start time from which gameplay should be observed. /// /// - /// Intermediate hitobject judgements may not be applied or reverted correctly during this seek. + /// This performs a non-frame-stable seek. Intermediate hitobject judgements may not be applied or reverted correctly during this seek. /// /// The destination time to seek to. - internal void NonFrameStableSeek(double time) + protected void SetGameplayStartTime(double time) { - // TODO: This schedule should not be required and is a temporary hotfix. - // See https://github.com/ppy/osu/issues/17267 for the issue. - // See https://github.com/ppy/osu/pull/17302 for a better fix which needs some more time. - ScheduleAfterChildren(() => - { - if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) - frameStablePlaybackResetDelegate.RunTask(); + if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) + frameStablePlaybackResetDelegate.RunTask(); - bool wasFrameStable = DrawableRuleset.FrameStablePlayback; - DrawableRuleset.FrameStablePlayback = false; + bool wasFrameStable = DrawableRuleset.FrameStablePlayback; + DrawableRuleset.FrameStablePlayback = false; - Seek(time); + GameplayClockContainer.StartTime = time; + GameplayClockContainer.Reset(); - // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. - frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); - }); + // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. + frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); } /// @@ -987,7 +982,7 @@ namespace osu.Game.Screens.Play if (GameplayClockContainer.GameplayClock.IsRunning) throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"); - GameplayClockContainer.Reset(); + GameplayClockContainer.Reset(true); } public override void OnSuspending(IScreen next) diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index c415041081..c0682952c3 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -78,7 +78,7 @@ namespace osu.Game.Screens.Play } if (isFirstBundle && score.Replay.Frames.Count > 0) - NonFrameStableSeek(score.Replay.Frames[0].Time); + SetGameplayStartTime(score.Replay.Frames[0].Time); } protected override Score CreateScore(IBeatmap beatmap) => score; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 4a974cf61d..21774b73a0 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -7,19 +7,16 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Development; using osu.Framework.Extensions; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Multiplayer { @@ -141,16 +138,6 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (Room.State) { case MultiplayerRoomState.Open: - // If there are no remaining ready users or the host is not ready, stop any existing countdown. - // Todo: This doesn't yet support non-match-start countdowns. - if (Room.Settings.AutoStartEnabled) - { - bool shouldHaveCountdown = !APIRoom.Playlist.GetCurrentItem()!.Expired && Room.Users.Any(u => u.State == MultiplayerUserState.Ready); - - if (shouldHaveCountdown && Room.Countdown == null) - startCountdown(new MatchStartCountdown { TimeRemaining = Room.Settings.AutoStartDuration }, StartMatch); - } - break; case MultiplayerRoomState.WaitingForLoad: @@ -317,16 +304,6 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } - private CancellationTokenSource? countdownSkipSource; - private CancellationTokenSource? countdownStopSource; - private Task countdownTask = Task.CompletedTask; - - /// - /// Skips to the end of the currently-running countdown, if one is running, - /// and runs the callback (e.g. to start the match) as soon as possible unless the countdown has been cancelled. - /// - public void SkipToEndOfCountdown() => countdownSkipSource?.Cancel(); - public override async Task SendMatchRequest(MatchUserRequest request) { Debug.Assert(Room != null); @@ -334,14 +311,6 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (request) { - case StartMatchCountdownRequest matchCountdownRequest: - startCountdown(new MatchStartCountdown { TimeRemaining = matchCountdownRequest.Duration }, StartMatch); - break; - - case StopCountdownRequest _: - stopCountdown(); - break; - case ChangeTeamRequest changeTeam: TeamVersusRoomState roomState = (TeamVersusRoomState)Room.MatchState!; @@ -360,62 +329,6 @@ namespace osu.Game.Tests.Visual.Multiplayer } } - private void startCountdown(MultiplayerCountdown countdown, Func continuation) - { - Debug.Assert(Room != null); - Debug.Assert(ThreadSafety.IsUpdateThread); - - stopCountdown(); - - // Note that this will leak CTSs, however this is a test method and we haven't noticed foregoing disposal of non-linked CTSs to be detrimental. - // If necessary, this can be moved into the final schedule below, and the class-level fields be nulled out accordingly. - var stopSource = countdownStopSource = new CancellationTokenSource(); - var skipSource = countdownSkipSource = new CancellationTokenSource(); - - Task lastCountdownTask = countdownTask; - countdownTask = start(); - - async Task start() - { - await lastCountdownTask; - - Schedule(() => - { - if (stopSource.IsCancellationRequested) - return; - - Room.Countdown = countdown; - MatchEvent(new CountdownChangedEvent { Countdown = countdown }); - }); - - try - { - using (var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stopSource.Token, skipSource.Token)) - await Task.Delay(countdown.TimeRemaining, cancellationSource.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Clients need to be notified of cancellations in the following code. - } - - Schedule(() => - { - if (Room.Countdown != countdown) - return; - - Room.Countdown = null; - MatchEvent(new CountdownChangedEvent { Countdown = null }); - - if (stopSource.IsCancellationRequested) - return; - - continuation().WaitSafely(); - }); - } - } - - private void stopCountdown() => countdownStopSource?.Cancel(); - public override Task StartMatch() { Debug.Assert(Room != null); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 308ec7e7d6..be4bee7c9d 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -35,8 +35,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 311c9ba345..0a0cab91a4 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,8 +61,8 @@ - - + + @@ -84,7 +84,7 @@ - +