diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs index 28ee7bd813..33c3867f5a 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy InternalChildren = new Drawable[] { - explosion = new LegacyRollingCounter(skin, LegacyFont.Combo) + explosion = new LegacyRollingCounter(LegacyFont.Combo) { Alpha = 0.65f, Blending = BlendingParameters.Additive, @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy Origin = Anchor.Centre, Scale = new Vector2(1.5f), }, - counter = new LegacyRollingCounter(skin, LegacyFont.Combo) + counter = new LegacyRollingCounter(LegacyFont.Combo) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index b3889bc7d3..fbb9b3c466 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); - public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.2); + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.5); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 1550faee50..003646d654 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -6,6 +6,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.Mania.UI; @@ -24,6 +25,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } + /// + /// Gets the samples that are played by this object during gameplay. + /// + public ISampleInfo[] GetGameplaySamples() => Samples.Samples; + protected override float SamplePlaybackPosition { get diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index d2a9b69b60..0f02e2cd4b 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -27,6 +27,12 @@ namespace osu.Game.Rulesets.Mania.UI public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; + /// + /// For hitsounds played by this (i.e. not as a result of hitting a hitobject), + /// a certain number of samples are allowed to be played concurrently so that it feels better when spam-pressing the key. + /// + private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY; + /// /// The index of this column as part of the whole playfield. /// @@ -38,6 +44,7 @@ namespace osu.Game.Rulesets.Mania.UI internal readonly Container TopLevelContainer; private readonly DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; + private readonly Container hitSounds; public Container UnderlayElements => HitObjectArea.UnderlayElements; @@ -64,6 +71,12 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Both }, background, + hitSounds = new Container + { + Name = "Column samples pool", + RelativeSizeAxes = Axes.Both, + Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray() + }, TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; @@ -120,6 +133,8 @@ namespace osu.Game.Rulesets.Mania.UI HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); } + private int nextHitSoundIndex; + public bool OnPressed(ManiaAction action) { if (action != Action.Value) @@ -131,7 +146,15 @@ namespace osu.Game.Rulesets.Mania.UI HitObjectContainer.Objects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ?? HitObjectContainer.Objects.LastOrDefault(); - nextObject?.PlaySamples(); + if (nextObject is DrawableManiaHitObject maniaObject) + { + var hitSound = hitSounds[nextHitSoundIndex]; + + hitSound.Samples = maniaObject.GetGameplaySamples(); + hitSound.Play(); + + nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds; + } return true; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs index 0649989dc0..1fdcd73dde 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - Child = new SkinProvidingContainer(new DefaultSkin()) + Child = new SkinProvidingContainer(new DefaultSkin(null)) { RelativeSizeAxes = Axes.Both, Child = drawableHitCircle = new DrawableHitCircle(hitCircle) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index 7eb6898abc..959589620b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Scale = new Vector2(SPRITE_SCALE), Y = SPINNER_TOP_OFFSET + 115, }, - bonusCounter = new LegacySpriteText(source, LegacyFont.Score) + bonusCounter = new LegacySpriteText(LegacyFont.Score) { Alpha = 0f, Anchor = Anchor.TopCentre, @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Scale = new Vector2(SPRITE_SCALE), Position = new Vector2(-87, 445 + spm_hide_offset), }, - spmCounter = new LegacySpriteText(source, LegacyFont.Score) + spmCounter = new LegacySpriteText(LegacyFont.Score) { Anchor = Anchor.TopCentre, Origin = Anchor.TopRight, diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index ffe238c507..88302ebc57 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (!this.HasFont(LegacyFont.HitCircle)) return null; - return new LegacySpriteText(Source, LegacyFont.HitCircle) + return new LegacySpriteText(LegacyFont.HitCircle) { // stable applies a blanket 0.8x scale to hitcircle fonts Scale = new Vector2(0.8f), diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 377a33b527..14589f8e6c 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -53,9 +53,9 @@ namespace osu.Game.Tests.NonVisual.Multiplayer Client.RoomSetupAction = room => { room.State = MultiplayerRoomState.Playing; - room.Users.Add(new MultiplayerRoomUser(55) + room.Users.Add(new MultiplayerRoomUser(PLAYER_1_ID) { - User = new User { Id = 55 }, + User = new User { Id = PLAYER_1_ID }, State = MultiplayerUserState.Playing }); }; diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs new file mode 100644 index 0000000000..d4e591cf09 --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public class TestSceneCatchUpSyncManager : OsuTestScene + { + private TestManualClock master; + private CatchUpSyncManager syncManager; + + private TestSpectatorPlayerClock player1; + private TestSpectatorPlayerClock player2; + + [SetUp] + public void Setup() + { + syncManager = new CatchUpSyncManager(master = new TestManualClock()); + syncManager.AddPlayerClock(player1 = new TestSpectatorPlayerClock(1)); + syncManager.AddPlayerClock(player2 = new TestSpectatorPlayerClock(2)); + + Schedule(() => Child = syncManager); + } + + [Test] + public void TestMasterClockStartsWhenAllPlayerClocksHaveFrames() + { + setWaiting(() => player1, false); + assertMasterState(false); + assertPlayerClockState(() => player1, false); + assertPlayerClockState(() => player2, false); + + setWaiting(() => player2, false); + assertMasterState(true); + assertPlayerClockState(() => player1, true); + assertPlayerClockState(() => player2, true); + } + + [Test] + public void TestMasterClockDoesNotStartWhenNoneReadyForMaximumDelayTime() + { + AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + assertMasterState(false); + } + + [Test] + public void TestMasterClockStartsWhenAnyReadyForMaximumDelayTime() + { + setWaiting(() => player1, false); + AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + assertMasterState(true); + } + + [Test] + public void TestPlayerClockDoesNotCatchUpWhenSlightlyOutOfSync() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.SYNC_TARGET + 1); + assertCatchingUp(() => player1, false); + } + + [Test] + public void TestPlayerClockStartsCatchingUpWhenTooFarBehind() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); + assertCatchingUp(() => player1, true); + assertCatchingUp(() => player2, true); + } + + [Test] + public void TestPlayerClockKeepsCatchingUpWhenSlightlyOutOfSync() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); + setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET + 1); + assertCatchingUp(() => player1, true); + } + + [Test] + public void TestPlayerClockStopsCatchingUpWhenInSync() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 2); + setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET); + assertCatchingUp(() => player1, false); + assertCatchingUp(() => player2, true); + } + + [Test] + public void TestPlayerClockDoesNotStopWhenSlightlyAhead() + { + setAllWaiting(false); + + setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET); + assertCatchingUp(() => player1, false); + assertPlayerClockState(() => player1, true); + } + + [Test] + public void TestPlayerClockStopsWhenTooFarAheadAndStartsWhenBackInSync() + { + setAllWaiting(false); + + setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET - 1); + + // This is a silent catchup, where IsCatchingUp = false but IsRunning = false also. + assertCatchingUp(() => player1, false); + assertPlayerClockState(() => player1, false); + + setMasterTime(1); + assertCatchingUp(() => player1, false); + assertPlayerClockState(() => player1, true); + } + + [Test] + public void TestInSyncPlayerClockDoesNotStartIfWaitingOnFrames() + { + setAllWaiting(false); + + assertPlayerClockState(() => player1, true); + setWaiting(() => player1, true); + assertPlayerClockState(() => player1, false); + } + + private void setWaiting(Func playerClock, bool waiting) + => AddStep($"set player clock {playerClock().Id} waiting = {waiting}", () => playerClock().WaitingOnFrames.Value = waiting); + + private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () => + { + player1.WaitingOnFrames.Value = waiting; + player2.WaitingOnFrames.Value = waiting; + }); + + private void setMasterTime(double time) + => AddStep($"set master = {time}", () => master.Seek(time)); + + /// + /// clock.Time = master.Time - offsetFromMaster + /// + private void setPlayerClockTime(Func playerClock, double offsetFromMaster) + => AddStep($"set player clock {playerClock().Id} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); + + private void assertMasterState(bool running) + => AddAssert($"master clock {(running ? "is" : "is not")} running", () => master.IsRunning == running); + + private void assertCatchingUp(Func playerClock, bool catchingUp) => + AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); + + private void assertPlayerClockState(Func playerClock, bool running) + => AddAssert($"player clock {playerClock().Id} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running); + + private class TestSpectatorPlayerClock : TestManualClock, ISpectatorPlayerClock + { + public Bindable WaitingOnFrames { get; } = new Bindable(true); + + public bool IsCatchingUp { get; set; } + + public IFrameBasedClock Source + { + set => throw new NotImplementedException(); + } + + public readonly int Id; + + public TestSpectatorPlayerClock(int id) + { + Id = id; + + WaitingOnFrames.BindValueChanged(waiting => + { + if (waiting.NewValue) + Stop(); + else + Start(); + }); + } + + public void ProcessFrame() + { + } + + public double ElapsedFrameTime => 0; + + public double FramesPerSecond => 0; + + public FrameTimeInfo TimeInfo => default; + } + + private class TestManualClock : ManualClock, IAdjustableClock + { + public void Start() => IsRunning = true; + + public void Stop() => IsRunning = false; + + public bool Seek(double position) + { + CurrentTime = position; + return true; + } + + public void Reset() + { + } + + public void ResetSpeedAdjustments() + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index e383aa8008..d5cfeb1878 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -167,5 +167,21 @@ namespace osu.Game.Tests.Visual.Editing AddStep("move mouse away", () => InputManager.MoveMouseTo(selectionBox, new Vector2(20))); AddUntilStep("rotation handle hidden", () => rotationHandle.Alpha == 0); } + + /// + /// Tests that hovering over two handles instantaneously from one to another does not crash or cause issues to the visibility state. + /// + [Test] + public void TestHoverOverTwoHandlesInstantaneously() + { + AddStep("hover over top-left scale handle", () => + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == Anchor.TopLeft))); + AddStep("hover over top-right scale handle", () => + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == Anchor.TopRight))); + AddUntilStep("top-left rotation handle hidden", () => + this.ChildrenOfType().Single(r => r.Anchor == Anchor.TopLeft).Alpha == 0); + AddUntilStep("top-right rotation handle shown", () => + this.ChildrenOfType().Single(r => r.Anchor == Anchor.TopRight).Alpha == 1); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs index b0a0b5189f..b22af0f7ac 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs @@ -1,22 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneComboCounter : SkinnableTestScene { - private IEnumerable comboCounters => CreatedDrawables.OfType(); - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); [Cached] @@ -25,7 +21,7 @@ namespace osu.Game.Tests.Visual.Gameplay [SetUpSteps] public void SetUpSteps() { - AddStep("Create combo counters", () => SetContents(() => new SkinnableComboCounter())); + AddStep("Create combo counters", () => SetContents(() => new SkinnableDrawable(new HUDSkinComponent(HUDSkinComponents.ComboCounter)))); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index c53ac42d12..a0b27755b7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -2,10 +2,12 @@ // 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.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; using osu.Game.Skinning.Editor; namespace osu.Game.Tests.Visual.Gameplay @@ -14,12 +16,17 @@ namespace osu.Game.Tests.Visual.Gameplay { private SkinEditor skinEditor; + [Resolved] + private SkinManager skinManager { get; set; } + + protected override bool Autoplay => true; + [SetUpSteps] public override void SetUpSteps() { base.SetUpSteps(); - AddStep("add editor overlay", () => + AddStep("reload skin editor", () => { skinEditor?.Expire(); Player.ScaleTo(SkinEditorOverlay.VISIBLE_TARGET_SCALE); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index c7c93b8892..245e190b1f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; @@ -32,12 +30,13 @@ namespace osu.Game.Tests.Visual.Gameplay SetContents(() => { var ruleset = new OsuRuleset(); + var mods = new[] { ruleset.GetAutoplayMod() }; var working = CreateWorkingBeatmap(ruleset.RulesetInfo); - var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo); + var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo, mods); - var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap, mods); - var hudOverlay = new HUDOverlay(drawableRuleset, Array.Empty()) + var hudOverlay = new HUDOverlay(drawableRuleset, mods) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs index 6a8a2187f9..6f4e6a2420 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs @@ -7,7 +7,7 @@ using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void SetUpSteps() { AddStep("Set initial accuracy", () => scoreProcessor.Accuracy.Value = 1); - AddStep("Create accuracy counters", () => SetContents(() => new SkinnableAccuracyCounter())); + AddStep("Create accuracy counters", () => SetContents(() => new SkinnableDrawable(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter)))); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs index 4f50613416..ead27bf017 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Testing; @@ -12,14 +10,12 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSkinnableHealthDisplay : SkinnableTestScene { - private IEnumerable healthDisplays => CreatedDrawables.OfType(); - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); [Cached(typeof(HealthProcessor))] @@ -28,10 +24,7 @@ namespace osu.Game.Tests.Visual.Gameplay [SetUpSteps] public void SetUpSteps() { - AddStep("Create health displays", () => - { - SetContents(() => new SkinnableHealthDisplay()); - }); + AddStep("Create health displays", () => SetContents(() => new SkinnableDrawable(new HUDSkinComponent(HUDSkinComponents.HealthDisplay)))); AddStep(@"Reset all", delegate { healthProcessor.Health.Value = 1; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs index 4f2183711e..8d633c3ca2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -1,23 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSkinnableScoreCounter : SkinnableTestScene { - private IEnumerable scoreCounters => CreatedDrawables.OfType(); - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); [Cached] @@ -26,7 +21,7 @@ namespace osu.Game.Tests.Visual.Gameplay [SetUpSteps] public void SetUpSteps() { - AddStep("Create score counters", () => SetContents(() => new SkinnableScoreCounter())); + AddStep("Create score counters", () => SetContents(() => new SkinnableDrawable(new HUDSkinComponent(HUDSkinComponents.ScoreCounter)))); } [Test] @@ -40,7 +35,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestVeryLargeScore() { - AddStep("set large score", () => scoreCounters.ForEach(counter => scoreProcessor.TotalScore.Value = 1_000_000_000)); + AddStep("set large score", () => scoreProcessor.TotalScore.Value = 1_000_000_000); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 74ce66096e..a7ed217b4d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -1,34 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Online; using osu.Game.Online.Spectator; -using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.UI; -using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Tests.Visual.Spectator; using osu.Game.Users; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSpectator : ScreenTestScene { + private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" }; + [Cached(typeof(SpectatorStreamingClient))] private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); @@ -214,9 +212,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player); - private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(beatmapId ?? importedBeatmapId)); + private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); - private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(beatmapId ?? importedBeatmapId)); + private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); @@ -225,89 +223,17 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("send frames", () => { - testSpectatorStreamingClient.SendFrames(nextFrame, count); + testSpectatorStreamingClient.SendFrames(streamingUser.Id, nextFrame, count); nextFrame += count; }); } private void loadSpectatingScreen() { - AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(testSpectatorStreamingClient.StreamingUser))); + AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); } - public class TestSpectatorStreamingClient : SpectatorStreamingClient - { - public readonly User StreamingUser = new User { Id = 55, Username = "Test user" }; - - public new BindableList PlayingUsers => (BindableList)base.PlayingUsers; - - private int beatmapId; - - public TestSpectatorStreamingClient() - : base(new DevelopmentEndpointConfiguration()) - { - } - - public void StartPlay(int beatmapId) - { - this.beatmapId = beatmapId; - sendState(beatmapId); - } - - public void EndPlay(int beatmapId) - { - ((ISpectatorClient)this).UserFinishedPlaying(StreamingUser.Id, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - - sentState = false; - } - - private bool sentState; - - public void SendFrames(int index, int count) - { - var frames = new List(); - - for (int i = index; i < index + count; i++) - { - var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1; - - frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); - } - - var bundle = new FrameDataBundle(new ScoreInfo(), frames); - ((ISpectatorClient)this).UserSentFrames(StreamingUser.Id, bundle); - - if (!sentState) - sendState(beatmapId); - } - - public override void WatchUser(int userId) - { - if (!PlayingUsers.Contains(userId) && sentState) - { - // usually the server would do this. - sendState(beatmapId); - } - - base.WatchUser(userId); - } - - private void sendState(int beatmapId) - { - sentState = true; - ((ISpectatorClient)this).UserBeganPlaying(StreamingUser.Id, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - } - } - internal class TestUserLookupCache : UserLookupCache { protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs similarity index 51% rename from osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorLeaderboard.cs rename to osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 3b2cfb1c7b..263adc07e1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -11,20 +11,17 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Database; -using osu.Game.Online; using osu.Game.Online.Spectator; -using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.Spectator; using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerSpectatorLeaderboard : MultiplayerTestScene + public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene { [Cached(typeof(SpectatorStreamingClient))] private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); @@ -37,11 +34,11 @@ namespace osu.Game.Tests.Visual.Multiplayer private readonly Dictionary clocks = new Dictionary { - { 55, new ManualClock() }, - { 56, new ManualClock() } + { PLAYER_1_ID, new ManualClock() }, + { PLAYER_2_ID, new ManualClock() } }; - public TestSceneMultiplayerSpectatorLeaderboard() + public TestSceneMultiSpectatorLeaderboard() { base.Content.AddRange(new Drawable[] { @@ -54,7 +51,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUpSteps] public new void SetUpSteps() { - MultiplayerSpectatorLeaderboard leaderboard = null; + MultiSpectatorLeaderboard leaderboard = null; AddStep("reset", () => { @@ -78,7 +75,7 @@ namespace osu.Game.Tests.Visual.Multiplayer var scoreProcessor = new OsuScoreProcessor(); scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiplayerSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add); + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add); }); AddUntilStep("wait for load", () => leaderboard.IsLoaded); @@ -95,46 +92,46 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("send frames", () => { - // For user 55, send frames in sets of 1. - // For user 56, send frames in sets of 10. + // For player 1, send frames in sets of 1. + // For player 2, send frames in sets of 10. for (int i = 0; i < 100; i++) { - streamingClient.SendFrames(55, i, 1); + streamingClient.SendFrames(PLAYER_1_ID, i, 1); if (i % 10 == 0) - streamingClient.SendFrames(56, i, 10); + streamingClient.SendFrames(PLAYER_2_ID, i, 10); } }); - assertCombo(55, 1); - assertCombo(56, 10); + assertCombo(PLAYER_1_ID, 1); + assertCombo(PLAYER_2_ID, 10); - // Advance to a point where only user 55's frame changes. + // Advance to a point where only user player 1's frame changes. setTime(500); - assertCombo(55, 5); - assertCombo(56, 10); + assertCombo(PLAYER_1_ID, 5); + assertCombo(PLAYER_2_ID, 10); // Advance to a point where both user's frame changes. setTime(1100); - assertCombo(55, 11); - assertCombo(56, 20); + assertCombo(PLAYER_1_ID, 11); + assertCombo(PLAYER_2_ID, 20); - // Advance user 56 only to a point where its frame changes. - setTime(56, 2100); - assertCombo(55, 11); - assertCombo(56, 30); + // Advance user player 2 only to a point where its frame changes. + setTime(PLAYER_2_ID, 2100); + assertCombo(PLAYER_1_ID, 11); + assertCombo(PLAYER_2_ID, 30); // Advance both users beyond their last frame setTime(101 * 100); - assertCombo(55, 100); - assertCombo(56, 100); + assertCombo(PLAYER_1_ID, 100); + assertCombo(PLAYER_2_ID, 100); } [Test] public void TestNoFrames() { - assertCombo(55, 0); - assertCombo(56, 0); + assertCombo(PLAYER_1_ID, 0); + assertCombo(PLAYER_2_ID, 0); } private void setTime(double time) => AddStep($"set time {time}", () => @@ -149,71 +146,6 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertCombo(int userId, int expectedCombo) => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); - private class TestSpectatorStreamingClient : SpectatorStreamingClient - { - private readonly Dictionary userBeatmapDictionary = new Dictionary(); - private readonly Dictionary userSentStateDictionary = new Dictionary(); - - public TestSpectatorStreamingClient() - : base(new DevelopmentEndpointConfiguration()) - { - } - - public void StartPlay(int userId, int beatmapId) - { - userBeatmapDictionary[userId] = beatmapId; - userSentStateDictionary[userId] = false; - sendState(userId, beatmapId); - } - - public void EndPlay(int userId, int beatmapId) - { - ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - userSentStateDictionary[userId] = false; - } - - public void SendFrames(int userId, int index, int count) - { - var frames = new List(); - - for (int i = index; i < index + count; i++) - { - var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1; - frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); - } - - var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames); - ((ISpectatorClient)this).UserSentFrames(userId, bundle); - if (!userSentStateDictionary[userId]) - sendState(userId, userBeatmapDictionary[userId]); - } - - public override void WatchUser(int userId) - { - if (userSentStateDictionary[userId]) - { - // usually the server would do this. - sendState(userId, userBeatmapDictionary[userId]); - } - - base.WatchUser(userId); - } - - private void sendState(int userId, int beatmapId) - { - ((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - userSentStateDictionary[userId] = true; - } - } - private class TestUserLookupCache : UserLookupCache { protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs new file mode 100644 index 0000000000..689c249d05 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -0,0 +1,313 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Spectator; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiSpectatorScreen : MultiplayerTestScene + { + [Cached(typeof(SpectatorStreamingClient))] + private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); + + [Resolved] + private OsuGameBase game { get; set; } + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private MultiSpectatorScreen spectatorScreen; + + private readonly List playingUserIds = new List(); + private readonly Dictionary nextFrame = new Dictionary(); + + private BeatmapSetInfo importedSet; + private BeatmapInfo importedBeatmap; + private int importedBeatmapId; + + [BackgroundDependencyLoader] + private void load() + { + importedSet = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; + importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0); + importedBeatmapId = importedBeatmap.OnlineBeatmapID ?? -1; + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset sent frames", () => nextFrame.Clear()); + + AddStep("add streaming client", () => + { + Remove(streamingClient); + Add(streamingClient); + }); + + AddStep("finish previous gameplay", () => + { + foreach (var id in playingUserIds) + streamingClient.EndPlay(id, importedBeatmapId); + playingUserIds.Clear(); + }); + } + + [Test] + public void TestDelayedStart() + { + AddStep("start players silently", () => + { + Client.CurrentMatchPlayingUserIds.Add(PLAYER_1_ID); + Client.CurrentMatchPlayingUserIds.Add(PLAYER_2_ID); + playingUserIds.Add(PLAYER_1_ID); + playingUserIds.Add(PLAYER_2_ID); + nextFrame[PLAYER_1_ID] = 0; + nextFrame[PLAYER_2_ID] = 0; + }); + + loadSpectateScreen(false); + + AddWaitStep("wait a bit", 10); + AddStep("load player first_player_id", () => streamingClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); + AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType().Count() == 1); + + AddWaitStep("wait a bit", 10); + AddStep("load player second_player_id", () => streamingClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); + AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType().Count() == 2); + } + + [Test] + public void TestGeneral() + { + int[] userIds = Enumerable.Range(0, 4).Select(i => PLAYER_1_ID + i).ToArray(); + + start(userIds); + loadSpectateScreen(); + + sendFrames(userIds, 1000); + AddWaitStep("wait a bit", 20); + } + + [Test] + public void TestPlayersMustStartSimultaneously() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send frames for one player only, both should remain paused. + sendFrames(PLAYER_1_ID, 20); + checkPausedInstant(PLAYER_1_ID, true); + checkPausedInstant(PLAYER_2_ID, true); + + // Send frames for the other player, both should now start playing. + sendFrames(PLAYER_2_ID, 20); + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, false); + } + + [Test] + public void TestPlayersDoNotStartSimultaneouslyIfBufferingForMaximumStartDelay() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send frames for one player only, both should remain paused. + sendFrames(PLAYER_1_ID, 1000); + checkPausedInstant(PLAYER_1_ID, true); + checkPausedInstant(PLAYER_2_ID, true); + + // Wait for the start delay seconds... + AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + + // Player 1 should start playing by itself, player 2 should remain paused. + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, true); + } + + [Test] + public void TestPlayersContinueWhileOthersBuffer() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send initial frames for both players. A few more for player 1. + sendFrames(PLAYER_1_ID, 20); + sendFrames(PLAYER_2_ID, 10); + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, false); + + // Eventually player 2 will pause, player 1 must remain running. + checkPaused(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID, false); + + // Eventually both players will run out of frames and should pause. + checkPaused(PLAYER_1_ID, true); + checkPausedInstant(PLAYER_2_ID, true); + + // Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused. + sendFrames(PLAYER_1_ID, 20); + checkPausedInstant(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID, false); + + // Send more frames for the second player. Both should be playing + sendFrames(PLAYER_2_ID, 20); + checkPausedInstant(PLAYER_2_ID, false); + checkPausedInstant(PLAYER_1_ID, false); + } + + [Test] + public void TestPlayersCatchUpAfterFallingBehind() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send initial frames for both players. A few more for player 1. + sendFrames(PLAYER_1_ID, 1000); + sendFrames(PLAYER_2_ID, 10); + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, false); + + // Eventually player 2 will run out of frames and should pause. + checkPaused(PLAYER_2_ID, true); + AddWaitStep("wait a few more frames", 10); + + // Send more frames for player 2. It should unpause. + sendFrames(PLAYER_2_ID, 1000); + checkPausedInstant(PLAYER_2_ID, false); + + // Player 2 should catch up to player 1 after unpausing. + waitForCatchup(PLAYER_2_ID); + AddWaitStep("wait a bit", 10); + } + + [Test] + public void TestMostInSyncUserIsAudioSource() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, true); + + sendFrames(PLAYER_1_ID, 10); + sendFrames(PLAYER_2_ID, 20); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + + checkPaused(PLAYER_1_ID, true); + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, false); + + sendFrames(PLAYER_1_ID, 100); + waitForCatchup(PLAYER_1_ID); + checkPaused(PLAYER_2_ID, true); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + + sendFrames(PLAYER_2_ID, 100); + waitForCatchup(PLAYER_2_ID); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + } + + private void loadSpectateScreen(bool waitForPlayerLoad = true) + { + AddStep("load screen", () => + { + Beatmap.Value = beatmapManager.GetWorkingBeatmap(importedBeatmap); + Ruleset.Value = importedBeatmap.Ruleset; + + LoadScreen(spectatorScreen = new MultiSpectatorScreen(playingUserIds.ToArray())); + }); + + AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); + } + + private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId); + + private void start(int[] userIds, int? beatmapId = null) + { + AddStep("start play", () => + { + foreach (int id in userIds) + { + Client.CurrentMatchPlayingUserIds.Add(id); + streamingClient.StartPlay(id, beatmapId ?? importedBeatmapId); + playingUserIds.Add(id); + nextFrame[id] = 0; + } + }); + } + + private void finish(int userId, int? beatmapId = null) + { + AddStep("end play", () => + { + streamingClient.EndPlay(userId, beatmapId ?? importedBeatmapId); + playingUserIds.Remove(userId); + nextFrame.Remove(userId); + }); + } + + private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count); + + private void sendFrames(int[] userIds, int count = 10) + { + AddStep("send frames", () => + { + foreach (int id in userIds) + { + streamingClient.SendFrames(id, nextFrame[id], count); + nextFrame[id] += count; + } + }); + } + + private void checkPaused(int userId, bool state) + => AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + + private void checkPausedInstant(int userId, bool state) + => AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + + private void assertMuted(int userId, bool muted) + => AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted); + + private void waitForCatchup(int userId) + => AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp); + + private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType().Single(); + + private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId); + + internal class TestUserLookupCache : UserLookupCache + { + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) + { + return Task.FromResult(new User + { + Id = lookup, + Username = $"User {lookup}" + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 78bc51e47b..bba7e2b391 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -1,9 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -11,7 +27,158 @@ namespace osu.Game.Tests.Visual.Multiplayer { private TestMultiplayer multiplayerScreen; + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; + + private TestMultiplayerClient client => multiplayerScreen.Client; + private Room room => client.APIRoom; + public TestSceneMultiplayer() + { + loadMultiplayer(); + } + + [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)); + } + + [SetUp] + public void Setup() => Schedule(() => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + }); + + [Test] + public void TestUserSetToIdleWhenBeatmapDeleted() + { + loadMultiplayer(); + + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("set user ready", () => client.ChangeState(MultiplayerUserState.Ready)); + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + + AddUntilStep("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle); + } + + [Test] + public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap() + { + loadMultiplayer(); + + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("join other user (ready, host)", () => + { + client.AddUser(new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Other" }); + client.TransferHost(MultiplayerTestScene.PLAYER_1_ID); + client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready); + }); + + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + + AddStep("click spectate button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("start match externally", () => client.StartMatch()); + + AddAssert("play not started", () => multiplayerScreen.IsCurrentScreen()); + } + + [Test] + public void TestLocalPlayStartsWhileSpectatingWhenBeatmapBecomesAvailable() + { + loadMultiplayer(); + + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + + AddStep("join other user (ready, host)", () => + { + client.AddUser(new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Other" }); + client.TransferHost(MultiplayerTestScene.PLAYER_1_ID); + client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready); + }); + + AddStep("click spectate button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("start match externally", () => client.StartMatch()); + + AddStep("restore beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + }); + + AddUntilStep("play started", () => !multiplayerScreen.IsCurrentScreen()); + } + + private void createRoom(Func room) + { + AddStep("open room", () => + { + multiplayerScreen.OpenNewRoom(room()); + }); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddWaitStep("wait for transition", 2); + + AddStep("create room", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for join", () => client.Room != null); + } + + private void loadMultiplayer() { AddStep("show", () => { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index b6c06bb149..6813a6e7dd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -6,14 +6,12 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Database; -using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; @@ -22,6 +20,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Visual.Online; +using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Multiplayer { @@ -30,7 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private const int users = 16; [Cached(typeof(SpectatorStreamingClient))] - private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(users); + private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(); [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache(); @@ -71,7 +70,8 @@ namespace osu.Game.Tests.Visual.Multiplayer var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - streamingClient.Start(Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); + for (int i = 0; i < users; i++) + streamingClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); Client.CurrentMatchPlayingUserIds.Clear(); Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers); @@ -114,30 +114,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); } - public class TestMultiplayerStreaming : SpectatorStreamingClient + public class TestMultiplayerStreaming : TestSpectatorStreamingClient { - public new BindableList PlayingUsers => (BindableList)base.PlayingUsers; - - private readonly int totalUsers; - - public TestMultiplayerStreaming(int totalUsers) - : base(new DevelopmentEndpointConfiguration()) - { - this.totalUsers = totalUsers; - } - - public void Start(int beatmapId) - { - for (int i = 0; i < totalUsers; i++) - { - ((ISpectatorClient)this).UserBeganPlaying(i, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - } - } - private readonly Dictionary lastHeaders = new Dictionary(); public void RandomlyUpdateState() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 7c6c158b5a..f611d5fecf 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -119,8 +119,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("join other user (ready)", () => { - Client.AddUser(new User { Id = 55 }); - Client.ChangeUserState(55, MultiplayerUserState.Ready); + Client.AddUser(new User { Id = PLAYER_1_ID }); + Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready); }); AddStep("click spectate button", () => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index e65e4a68a7..e59b342176 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -120,9 +120,12 @@ namespace osu.Game.Tests.Visual.Multiplayer }; }); - [Test] - public void TestEnabledWhenRoomOpen() + [TestCase(MultiplayerRoomState.Open)] + [TestCase(MultiplayerRoomState.WaitingForLoad)] + [TestCase(MultiplayerRoomState.Playing)] + public void TestEnabledWhenRoomOpenOrInGameplay(MultiplayerRoomState roomState) { + AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState)); assertSpectateButtonEnablement(true); } @@ -137,12 +140,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); } - [TestCase(MultiplayerRoomState.WaitingForLoad)] - [TestCase(MultiplayerRoomState.Playing)] [TestCase(MultiplayerRoomState.Closed)] - public void TestDisabledDuringGameplayOrClosed(MultiplayerRoomState roomState) + public void TestDisabledWhenClosed(MultiplayerRoomState roomState) { - AddStep($"change user to {roomState}", () => Client.ChangeRoomState(roomState)); + AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState)); assertSpectateButtonEnablement(false); } @@ -156,8 +157,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestReadyButtonEnabledWhenHostAndUsersReady() { - AddStep("add user", () => Client.AddUser(new User { Id = 55 })); - AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready)); + AddStep("add user", () => Client.AddUser(new User { Id = PLAYER_1_ID })); + AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready)); addClickSpectateButtonStep(); assertReadyButtonEnablement(true); @@ -168,11 +169,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("add user and transfer host", () => { - Client.AddUser(new User { Id = 55 }); - Client.TransferHost(55); + Client.AddUser(new User { Id = PLAYER_1_ID }); + Client.TransferHost(PLAYER_1_ID); }); - AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready)); + AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready)); addClickSpectateButtonStep(); assertReadyButtonEnablement(false); diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index 1baa07f208..8ae6398003 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs @@ -12,7 +12,7 @@ using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Online.Spectator; using osu.Game.Overlays.Dashboard; -using osu.Game.Tests.Visual.Gameplay; +using osu.Game.Tests.Visual.Spectator; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Online public class TestSceneCurrentlyPlayingDisplay : OsuTestScene { [Cached(typeof(SpectatorStreamingClient))] - private TestSceneSpectator.TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSceneSpectator.TestSpectatorStreamingClient(); + private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); private CurrentlyPlayingDisplay currentlyPlaying; diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index e0eeaf6db0..3576b149bf 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -324,7 +324,7 @@ namespace osu.Game.Beatmaps public bool SkinLoaded => skin.IsResultAvailable; public ISkin Skin => skin.Value; - protected virtual ISkin GetSkin() => new DefaultSkin(); + protected virtual ISkin GetSkin() => new DefaultSkin(null); private readonly RecyclableLazy skin; public abstract Stream GetStream(string storagePath); diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index dbeaebb1cd..e0f80d2743 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -472,7 +472,7 @@ namespace osu.Game.Database } /// - /// Delete new file. + /// Delete an existing file. /// /// The item to operate on. /// The existing file to be deleted. diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index a8de3f6407..2ac6e6ff22 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -3,8 +3,10 @@ using System; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Threading; +using osu.Game.Screens.Play.HUD; using osuTK; namespace osu.Game.Extensions @@ -43,5 +45,23 @@ namespace osu.Game.Extensions /// The delta vector in Parent's coordinates. public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) => drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta); + + public static SkinnableInfo CreateSkinnableInfo(this Drawable component) => new SkinnableInfo(component); + + public static void ApplySkinnableInfo(this Drawable component, SkinnableInfo info) + { + // todo: can probably make this better via deserialisation directly using a common interface. + component.Position = info.Position; + component.Rotation = info.Rotation; + component.Scale = info.Scale; + component.Anchor = info.Anchor; + component.Origin = info.Origin; + + if (component is Container container) + { + foreach (var child in info.Children) + container.Add(child.CreateInstance()); + } + } } } diff --git a/osu.Game/Extensions/TypeExtensions.cs b/osu.Game/Extensions/TypeExtensions.cs new file mode 100644 index 0000000000..2e93c81758 --- /dev/null +++ b/osu.Game/Extensions/TypeExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; + +namespace osu.Game.Extensions +{ + internal static class TypeExtensions + { + /// + /// Returns 's + /// with the assembly version, culture and public key token values removed. + /// + /// + /// This method is usually used in extensibility scenarios (i.e. for custom rulesets or skins) + /// when a version-agnostic identifier associated with a C# class - potentially originating from + /// an external assembly - is needed. + /// Leaving only the type and assembly names in such a scenario allows to preserve compatibility + /// across assembly versions. + /// + internal static string GetInvariantInstantiationInfo(this Type type) + { + string assemblyQualifiedName = type.AssemblyQualifiedName; + if (assemblyQualifiedName == null) + throw new ArgumentException($"{type}'s assembly-qualified name is null. Ensure that it is a concrete type and not a generic type parameter.", nameof(type)); + + return string.Join(',', assemblyQualifiedName.Split(',').Take(2)); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs b/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs new file mode 100644 index 0000000000..89a4c28c8c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs @@ -0,0 +1,18 @@ +// 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; + +namespace osu.Game.Graphics.UserInterface +{ + public class DangerousTriangleButton : TriangleButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.PinkDark; + Triangles.ColourDark = colours.PinkDarker; + Triangles.ColourLight = colours.Pink; + } + } +} diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index ce945f3bf8..c8227c0887 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -48,7 +48,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing), new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), - new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.ToggleSkinEditor), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), new KeyBinding(InputKey.Escape, GlobalAction.Back), new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs new file mode 100644 index 0000000000..b808c648da --- /dev/null +++ b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs @@ -0,0 +1,508 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using osu.Game.Database; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20210511060743_AddSkinInstantiationInfo")] + partial class AddSkinInstantiationInfo + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("ApproachRate"); + + b.Property("CircleSize"); + + b.Property("DrainRate"); + + b.Property("OverallDifficulty"); + + b.Property("SliderMultiplier"); + + b.Property("SliderTickRate"); + + b.HasKey("ID"); + + b.ToTable("BeatmapDifficulty"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("AudioLeadIn"); + + b.Property("BPM"); + + b.Property("BaseDifficultyID"); + + b.Property("BeatDivisor"); + + b.Property("BeatmapSetInfoID"); + + b.Property("Countdown"); + + b.Property("DistanceSpacing"); + + b.Property("EpilepsyWarning"); + + b.Property("GridSize"); + + b.Property("Hash"); + + b.Property("Hidden"); + + b.Property("Length"); + + b.Property("LetterboxInBreaks"); + + b.Property("MD5Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapID"); + + b.Property("Path"); + + b.Property("RulesetID"); + + b.Property("SpecialStyle"); + + b.Property("StackLeniency"); + + b.Property("StarDifficulty"); + + b.Property("Status"); + + b.Property("StoredBookmarks"); + + b.Property("TimelineZoom"); + + b.Property("Version"); + + b.Property("WidescreenStoryboard"); + + b.HasKey("ID"); + + b.HasIndex("BaseDifficultyID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("Hash"); + + b.HasIndex("MD5Hash"); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("BeatmapInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Artist"); + + b.Property("ArtistUnicode"); + + b.Property("AudioFile"); + + b.Property("AuthorString") + .HasColumnName("Author"); + + b.Property("BackgroundFile"); + + b.Property("PreviewTime"); + + b.Property("Source"); + + b.Property("Tags"); + + b.Property("Title"); + + b.Property("TitleUnicode"); + + b.HasKey("ID"); + + b.ToTable("BeatmapMetadata"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("BeatmapSetInfoID"); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.HasKey("ID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("FileInfoID"); + + b.ToTable("BeatmapSetFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("DateAdded"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapSetID"); + + b.Property("Protected"); + + b.Property("Status"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapSetID") + .IsUnique(); + + b.ToTable("BeatmapSetInfo"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Key") + .HasColumnName("Key"); + + b.Property("RulesetID"); + + b.Property("SkinInfoID"); + + b.Property("StringValue") + .HasColumnName("Value"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("SkinInfoID"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("osu.Game.IO.FileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Hash"); + + b.Property("ReferenceCount"); + + b.HasKey("ID"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("ReferenceCount"); + + b.ToTable("FileInfo"); + }); + + modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("IntAction") + .HasColumnName("Action"); + + b.Property("KeysString") + .HasColumnName("Keys"); + + b.Property("RulesetID"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("IntAction"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("KeyBinding"); + }); + + modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Available"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.Property("ShortName"); + + b.HasKey("ID"); + + b.HasIndex("Available"); + + b.HasIndex("ShortName") + .IsUnique(); + + b.ToTable("RulesetInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("ScoreInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("ScoreInfoID"); + + b.ToTable("ScoreFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Accuracy") + .HasColumnType("DECIMAL(1,4)"); + + b.Property("BeatmapInfoID"); + + b.Property("Combo"); + + b.Property("Date"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MaxCombo"); + + b.Property("ModsJson") + .HasColumnName("Mods"); + + b.Property("OnlineScoreID"); + + b.Property("PP"); + + b.Property("Rank"); + + b.Property("RulesetID"); + + b.Property("StatisticsJson") + .HasColumnName("Statistics"); + + b.Property("TotalScore"); + + b.Property("UserID") + .HasColumnName("UserID"); + + b.Property("UserString") + .HasColumnName("User"); + + b.HasKey("ID"); + + b.HasIndex("BeatmapInfoID"); + + b.HasIndex("OnlineScoreID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("ScoreInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("SkinInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("SkinInfoID"); + + b.ToTable("SkinFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Creator"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("SkinInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") + .WithMany() + .HasForeignKey("BaseDifficultyID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") + .WithMany("Beatmaps") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("Beatmaps") + .HasForeignKey("MetadataID"); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") + .WithMany("Files") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("BeatmapSets") + .HasForeignKey("MetadataID"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Settings") + .HasForeignKey("SkinInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Scoring.ScoreInfo") + .WithMany("Files") + .HasForeignKey("ScoreInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") + .WithMany("Scores") + .HasForeignKey("BeatmapInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Files") + .HasForeignKey("SkinInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs new file mode 100644 index 0000000000..1d5b0769a4 --- /dev/null +++ b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddSkinInstantiationInfo : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InstantiationInfo", + table: "SkinInfo", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InstantiationInfo", + table: "SkinInfo"); + } + } +} diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs index ec4461ca56..d4bde50b60 100644 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs @@ -141,8 +141,6 @@ namespace osu.Game.Migrations b.Property("TitleUnicode"); - b.Property("VideoFile"); - b.HasKey("ID"); b.ToTable("BeatmapMetadata"); @@ -352,7 +350,7 @@ namespace osu.Game.Migrations b.Property("TotalScore"); - b.Property("UserID") + b.Property("UserID") .HasColumnName("UserID"); b.Property("UserString") @@ -402,6 +400,8 @@ namespace osu.Game.Migrations b.Property("Hash"); + b.Property("InstantiationInfo"); + b.Property("Name"); b.HasKey("ID"); diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 37e11cc576..4529dfd0a7 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -96,9 +96,6 @@ namespace osu.Game.Online.Multiplayer if (!IsConnected.Value) return Task.CompletedTask; - if (newState == MultiplayerUserState.Spectating) - return Task.CompletedTask; // Not supported yet. - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); } diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs index 378096c7fb..ec6d1bf9d8 100644 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs @@ -142,7 +142,11 @@ namespace osu.Game.Online.Spectator if (!playingUsers.Contains(userId)) playingUsers.Add(userId); - playingUserStates[userId] = state; + // UserBeganPlaying() is called by the server regardless of whether the local user is watching the remote user, and is called a further time when the remote user is watched. + // This may be a temporary thing (see: https://github.com/ppy/osu-server-spectator/blob/2273778e02cfdb4a9c6a934f2a46a8459cb5d29c/osu.Server.Spectator/Hubs/SpectatorHub.cs#L28-L29). + // We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations. + if (watchingUsers.Contains(userId)) + playingUserStates[userId] = state; } OnUserBeganPlaying?.Invoke(userId, state); @@ -230,7 +234,7 @@ namespace osu.Game.Online.Spectator connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); } - public void StopWatchingUser(int userId) + public virtual void StopWatchingUser(int userId) { lock (userLock) { diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index 300fce962a..43942d2d52 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -339,22 +339,13 @@ namespace osu.Game.Overlays.KeyBinding } } - public class ClearButton : TriangleButton + public class ClearButton : DangerousTriangleButton { public ClearButton() { Text = "Clear"; Size = new Vector2(80, 20); } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BackgroundColour = colours.Pink; - - Triangles.ColourDark = colours.PinkDark; - Triangles.ColourLight = colours.PinkLight; - } } public class KeyButton : Container diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs index d784b7aec9..707176e63e 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs @@ -11,7 +11,6 @@ using osu.Game.Input; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osuTK; -using osu.Game.Graphics; namespace osu.Game.Overlays.KeyBinding { @@ -55,10 +54,10 @@ namespace osu.Game.Overlays.KeyBinding } } - public class ResetButton : TriangleButton + public class ResetButton : DangerousTriangleButton { [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Text = "Reset all bindings in section"; RelativeSizeAxes = Axes.X; @@ -66,10 +65,6 @@ namespace osu.Game.Overlays.KeyBinding Height = 20; Content.CornerRadius = 5; - - BackgroundColour = colours.PinkDark; - Triangles.ColourDark = colours.PinkDarker; - Triangles.ColourLight = colours.Pink; } } } diff --git a/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs b/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs index 1241e058ad..1f708209fe 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs @@ -18,7 +18,6 @@ namespace osu.Game.Rulesets.Edit.Checks.Components /// An error occurred and a complete check could not be made. Error, - // TODO: Negligible issues should be hidden by default. /// A possible mistake so minor/unlikely that it can often be safely ignored. Negligible, } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 7f0c27adfc..7bdf84ace4 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -26,6 +26,7 @@ using JetBrains.Annotations; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Testing; +using osu.Game.Extensions; using osu.Game.Rulesets.Filter; using osu.Game.Screens.Ranking.Statistics; @@ -135,7 +136,7 @@ namespace osu.Game.Rulesets Name = Description, ShortName = ShortName, ID = (this as ILegacyRuleset)?.LegacyID, - InstantiationInfo = GetType().AssemblyQualifiedName, + InstantiationInfo = GetType().GetInvariantInstantiationInfo(), Available = true, }; } diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index d5aca8c650..702bf35fa8 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Newtonsoft.Json; using osu.Framework.Testing; @@ -18,20 +17,7 @@ namespace osu.Game.Rulesets public string ShortName { get; set; } - private string instantiationInfo; - - public string InstantiationInfo - { - get => instantiationInfo; - set => instantiationInfo = abbreviateInstantiationInfo(value); - } - - private string abbreviateInstantiationInfo(string value) - { - // exclude version onwards, matching only on namespace and type. - // this is mainly to allow for new versions of already loaded rulesets to "upgrade" from old. - return string.Join(',', value.Split(',').Take(2)); - } + public string InstantiationInfo { get; set; } [JsonIgnore] public bool Available { get; set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 361e98e0dd..edd1acbd6c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -24,6 +26,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Includes selection and manipulation support via a . /// public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + where T : class { protected DragBox DragBox { get; private set; } @@ -39,6 +42,8 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } + protected readonly BindableList SelectedItems = new BindableList(); + protected BlueprintContainer() { RelativeSizeAxes = Axes.Both; @@ -47,6 +52,24 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { + SelectedItems.CollectionChanged += (selectedObjects, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var o in args.NewItems) + SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var o in args.OldItems) + SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); + + break; + } + }; + SelectionHandler = CreateSelectionHandler(); SelectionHandler.DeselectAll = deselectAll; diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index db322faf65..31a191c80c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -2,10 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -24,8 +22,6 @@ namespace osu.Game.Screens.Edit.Compose.Components protected readonly HitObjectComposer Composer; - private readonly BindableList selectedHitObjects = new BindableList(); - protected EditorBlueprintContainer(HitObjectComposer composer) { Composer = composer; @@ -34,23 +30,7 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - selectedHitObjects.BindTo(Beatmap.SelectedHitObjects); - selectedHitObjects.CollectionChanged += (selectedObjects, args) => - { - switch (args.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var o in args.NewItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); - break; - - case NotifyCollectionChangedAction.Remove: - foreach (var o in args.OldItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); - - break; - } - }; + SelectedItems.BindTo(Beatmap.SelectedHitObjects); } protected override void LoadComplete() diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs index 456f72878d..397158b9f6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs @@ -84,8 +84,8 @@ namespace osu.Game.Screens.Edit.Compose.Components if (activeHandle?.IsHeld == true) return; - activeHandle = rotationHandles.SingleOrDefault(h => h.IsHeld || h.IsHovered); - activeHandle ??= allDragHandles.SingleOrDefault(h => h.IsHovered); + activeHandle = rotationHandles.FirstOrDefault(h => h.IsHeld || h.IsHovered); + activeHandle ??= allDragHandles.FirstOrDefault(h => h.IsHovered); if (activeHandle != null) { diff --git a/osu.Game/Screens/Edit/RoundedContentEditorScreen.cs b/osu.Game/Screens/Edit/EditorRoundedScreen.cs similarity index 93% rename from osu.Game/Screens/Edit/RoundedContentEditorScreen.cs rename to osu.Game/Screens/Edit/EditorRoundedScreen.cs index a55ae3f635..c6ced02021 100644 --- a/osu.Game/Screens/Edit/RoundedContentEditorScreen.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreen.cs @@ -10,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Edit { - public class RoundedContentEditorScreen : EditorScreen + public class EditorRoundedScreen : EditorScreen { public const int HORIZONTAL_PADDING = 100; @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Edit protected override Container Content => roundedContent; - public RoundedContentEditorScreen(EditorScreenMode mode) + public EditorRoundedScreen(EditorScreenMode mode) : base(mode) { ColourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs new file mode 100644 index 0000000000..cb17484d27 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs @@ -0,0 +1,44 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit +{ + public abstract class EditorRoundedScreenSettings : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Background4, + RelativeSizeAxes = Axes.Both, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = CreateSections() + }, + } + }; + } + + protected abstract IReadOnlyList CreateSections(); + } +} diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs new file mode 100644 index 0000000000..e17114ebcb --- /dev/null +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit +{ + public abstract class EditorRoundedScreenSettingsSection : CompositeDrawable + { + private const int header_height = 50; + + protected abstract string HeaderText { get; } + + protected FillFlowContainer Flow { get; private set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = header_height, + Padding = new MarginPadding { Horizontal = 20 }, + Child = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = HeaderText, + Font = new FontUsage(size: 25, weight: "bold") + } + }, + new Container + { + Y = header_height, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = Flow = new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 20 }, + Spacing = new Vector2(10), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index 0af7cf095b..5bbec2574f 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -7,7 +7,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Edit.Setup { - public class SetupScreen : RoundedContentEditorScreen + public class SetupScreen : EditorRoundedScreen { [Cached] private SectionsContainer sections = new SectionsContainer(); diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs index 10daacc359..2d0afda001 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs @@ -93,7 +93,7 @@ namespace osu.Game.Screens.Edit.Setup public SetupScreenTabControl() { - TabContainer.Margin = new MarginPadding { Horizontal = RoundedContentEditorScreen.HORIZONTAL_PADDING }; + TabContainer.Margin = new MarginPadding { Horizontal = EditorRoundedScreen.HORIZONTAL_PADDING }; AddInternal(background = new Box { diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index b3ae15900f..8964e651df 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Edit.Setup Padding = new MarginPadding { Vertical = 10, - Horizontal = RoundedContentEditorScreen.HORIZONTAL_PADDING + Horizontal = EditorRoundedScreen.HORIZONTAL_PADDING }; InternalChild = new FillFlowContainer diff --git a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs index 921fa675b3..48639789af 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs @@ -2,44 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Containers; -using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Timing { - public class ControlPointSettings : CompositeDrawable + public class ControlPointSettings : EditorRoundedScreenSettings { - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Box - { - Colour = colours.Background4, - RelativeSizeAxes = Axes.Both, - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = createSections() - }, - } - }; - } - - private IReadOnlyList createSections() => new Drawable[] + protected override IReadOnlyList CreateSections() => new Drawable[] { new GroupSection(), new TimingSection(), diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index 9f26dece08..a4193d5084 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class TimingScreen : RoundedContentEditorScreen + public class TimingScreen : EditorRoundedScreen { [Cached] private Bindable selectedGroup = new Bindable(); diff --git a/osu.Game/Screens/Edit/Verify/InterpretationSection.cs b/osu.Game/Screens/Edit/Verify/InterpretationSection.cs new file mode 100644 index 0000000000..9548f8aaa9 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/InterpretationSection.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Screens.Edit.Verify +{ + internal class InterpretationSection : EditorRoundedScreenSettingsSection + { + protected override string HeaderText => "Interpretation"; + + [BackgroundDependencyLoader] + private void load(VerifyScreen verify) + { + Flow.Add(new SettingsEnumDropdown + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + TooltipText = "Affects checks that depend on difficulty level", + Current = verify.InterpretedDifficulty.GetBoundCopy() + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs new file mode 100644 index 0000000000..407c1e3bc7 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -0,0 +1,111 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osuTK; + +namespace osu.Game.Screens.Edit.Verify +{ + [Cached] + public class IssueList : CompositeDrawable + { + private IssueTable table; + + [Resolved] + private EditorClock clock { get; set; } + + [Resolved] + private IBindable workingBeatmap { get; set; } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private VerifyScreen verify { get; set; } + + private IBeatmapVerifier rulesetVerifier; + private BeatmapVerifier generalVerifier; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + generalVerifier = new BeatmapVerifier(); + rulesetVerifier = beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateBeatmapVerifier(); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Background2, + RelativeSizeAxes = Axes.Both, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = table = new IssueTable(), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(20), + Children = new Drawable[] + { + new TriangleButton + { + Text = "Refresh", + Action = refresh, + Size = new Vector2(120, 40), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + verify.InterpretedDifficulty.BindValueChanged(_ => refresh()); + verify.HiddenIssueTypes.BindCollectionChanged((_, __) => refresh()); + + refresh(); + } + + private void refresh() + { + var issues = generalVerifier.Run(beatmap, workingBeatmap.Value); + + if (rulesetVerifier != null) + issues = issues.Concat(rulesetVerifier.Run(beatmap, workingBeatmap.Value)); + + issues = filter(issues); + + table.Issues = issues + .OrderBy(issue => issue.Template.Type) + .ThenBy(issue => issue.Check.Metadata.Category); + } + + private IEnumerable filter(IEnumerable issues) + { + return issues.Where(issue => !verify.HiddenIssueTypes.Contains(issue.Template.Type)); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/IssueSettings.cs b/osu.Game/Screens/Edit/Verify/IssueSettings.cs index 4519231cd2..ae3ef7e0b0 100644 --- a/osu.Game/Screens/Edit/Verify/IssueSettings.cs +++ b/osu.Game/Screens/Edit/Verify/IssueSettings.cs @@ -2,45 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Edit.Verify { - public class IssueSettings : CompositeDrawable + public class IssueSettings : EditorRoundedScreenSettings { - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Box - { - Colour = colours.Gray3, - RelativeSizeAxes = Axes.Both, - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = createSections() - }, - } - }; - } - - private IReadOnlyList createSections() => new Drawable[] + protected override IReadOnlyList CreateSections() => new Drawable[] { + new InterpretationSection(), + new VisibilitySection() }; } } diff --git a/osu.Game/Screens/Edit/Verify/IssueTable.cs b/osu.Game/Screens/Edit/Verify/IssueTable.cs index 44244028c9..05a8fdd26d 100644 --- a/osu.Game/Screens/Edit/Verify/IssueTable.cs +++ b/osu.Game/Screens/Edit/Verify/IssueTable.cs @@ -18,7 +18,9 @@ namespace osu.Game.Screens.Edit.Verify public class IssueTable : EditorTable { [Resolved] - private Bindable selectedIssue { get; set; } + private VerifyScreen verify { get; set; } + + private Bindable selectedIssue; [Resolved] private EditorClock clock { get; set; } @@ -71,6 +73,7 @@ namespace osu.Game.Screens.Edit.Verify { base.LoadComplete(); + selectedIssue = verify.SelectedIssue.GetBoundCopy(); selectedIssue.BindValueChanged(issue => { foreach (var b in BackgroundFlow) b.Selected = b.Item == issue.NewValue; diff --git a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs index 9de1f04271..6d7a4a72e2 100644 --- a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs +++ b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs @@ -1,26 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks.Components; -using osuTK; namespace osu.Game.Screens.Edit.Verify { - public class VerifyScreen : RoundedContentEditorScreen + [Cached] + public class VerifyScreen : EditorRoundedScreen { - [Cached] - private Bindable selectedIssue = new Bindable(); + public readonly Bindable SelectedIssue = new Bindable(); + + public readonly Bindable InterpretedDifficulty = new Bindable(); + + public readonly BindableList HiddenIssueTypes = new BindableList { IssueType.Negligible }; + + public IssueList IssueList { get; private set; } public VerifyScreen() : base(EditorScreenMode.Verify) @@ -30,6 +29,10 @@ namespace osu.Game.Screens.Edit.Verify [BackgroundDependencyLoader] private void load() { + InterpretedDifficulty.Default = EditorBeatmap.BeatmapInfo.DifficultyRating; + InterpretedDifficulty.SetDefault(); + + IssueList = new IssueList(); Child = new Container { RelativeSizeAxes = Axes.Both, @@ -45,92 +48,12 @@ namespace osu.Game.Screens.Edit.Verify { new Drawable[] { - new IssueList(), + IssueList, new IssueSettings(), }, } } }; } - - public class IssueList : CompositeDrawable - { - private IssueTable table; - - [Resolved] - private EditorClock clock { get; set; } - - [Resolved] - private IBindable workingBeatmap { get; set; } - - [Resolved] - private EditorBeatmap beatmap { get; set; } - - [Resolved] - private Bindable selectedIssue { get; set; } - - private IBeatmapVerifier rulesetVerifier; - private BeatmapVerifier generalVerifier; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) - { - generalVerifier = new BeatmapVerifier(); - rulesetVerifier = beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateBeatmapVerifier(); - - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Box - { - Colour = colours.Background2, - RelativeSizeAxes = Axes.Both, - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = table = new IssueTable(), - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding(20), - Children = new Drawable[] - { - new TriangleButton - { - Text = "Refresh", - Action = refresh, - Size = new Vector2(120, 40), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - refresh(); - } - - private void refresh() - { - var issues = generalVerifier.Run(beatmap, workingBeatmap.Value); - - if (rulesetVerifier != null) - issues = issues.Concat(rulesetVerifier.Run(beatmap, workingBeatmap.Value)); - - table.Issues = issues - .OrderBy(issue => issue.Template.Type) - .ThenBy(issue => issue.Check.Metadata.Category); - } - } } } diff --git a/osu.Game/Screens/Edit/Verify/VisibilitySection.cs b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs new file mode 100644 index 0000000000..d049436376 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs @@ -0,0 +1,54 @@ +// 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.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Screens.Edit.Verify +{ + internal class VisibilitySection : EditorRoundedScreenSettingsSection + { + private readonly IssueType[] configurableIssueTypes = + { + IssueType.Warning, + IssueType.Error, + IssueType.Negligible + }; + + private BindableList hiddenIssueTypes; + + protected override string HeaderText => "Visibility"; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours, VerifyScreen verify) + { + hiddenIssueTypes = verify.HiddenIssueTypes.GetBoundCopy(); + + foreach (IssueType issueType in configurableIssueTypes) + { + var checkbox = new SettingsCheckbox + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + LabelText = issueType.ToString(), + Current = { Default = !hiddenIssueTypes.Contains(issueType) } + }; + + checkbox.Current.SetDefault(); + checkbox.Current.BindValueChanged(state => + { + if (!state.NewValue) + hiddenIssueTypes.Add(issueType); + else + hiddenIssueTypes.Remove(issueType); + }); + + Flow.Add(checkbox); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 706da05d15..68bdd9160e 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -17,7 +17,6 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Play; namespace osu.Game.Screens.OnlinePlay.Match { @@ -148,12 +147,18 @@ namespace osu.Game.Screens.OnlinePlay.Match return base.OnExiting(next); } - protected void StartPlay(Func player) + protected void StartPlay() { sampleStart?.Play(); - ParentScreen?.Push(new PlayerLoader(player)); + ParentScreen?.Push(CreateGameplayScreen()); } + /// + /// Creates the gameplay screen to be entered. + /// + /// The screen to enter. + protected abstract Screen CreateGameplayScreen(); + private void selectedItemChanged() { updateWorkingBeatmap(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 4b3fb5d00f..04150902bc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match break; } - button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value; + button.Enabled.Value = Client.Room?.State != MultiplayerRoomState.Closed && !operationInProgress.Value; } private class ButtonWithTrianglesExposed : TriangleButton diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index ae22e1fcec..085c824bdc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.OnResuming(last); - if (client.Room != null) + if (client.Room != null && client.LocalUser?.State != MultiplayerUserState.Spectating) client.ChangeState(MultiplayerUserState.Idle); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 90cef0107c..fa18b792c3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -14,6 +14,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -25,6 +26,7 @@ using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osuTK; @@ -353,10 +355,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.ChangeBeatmapAvailability(availability.NewValue); - // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. - if (availability.NewValue != Online.Rooms.BeatmapAvailability.LocallyAvailable() - && client.LocalUser?.State == MultiplayerUserState.Ready) - client.ChangeState(MultiplayerUserState.Idle); + if (availability.NewValue.State != DownloadState.LocallyAvailable) + { + // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. + if (client.LocalUser?.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle); + } + else + { + if (client.LocalUser?.State == MultiplayerUserState.Spectating && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) + onLoadRequested(); + } } private void onReadyClick() @@ -407,22 +416,46 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onRoomUpdated() { - // user mods may have changed. Scheduler.AddOnce(UpdateMods); } private void onLoadRequested() { - Debug.Assert(client.Room != null); + if (BeatmapAvailability.Value.State != DownloadState.LocallyAvailable) + return; - int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + // In the case of spectating, IMultiplayerClient.LoadRequested can be fired while the game is still spectating a previous session. + // For now, we want to game to switch to the new game so need to request exiting from the play screen. + if (!ParentScreen.IsCurrentScreen()) + { + ParentScreen.MakeCurrent(); - StartPlay(() => new MultiplayerPlayer(SelectedItem.Value, userIds)); + Schedule(onLoadRequested); + return; + } + + StartPlay(); readyClickOperation?.Dispose(); readyClickOperation = null; } + protected override Screen CreateGameplayScreen() + { + Debug.Assert(client.LocalUser != null); + + int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + + switch (client.LocalUser.State) + { + case MultiplayerUserState.Spectating: + return new MultiSpectatorScreen(userIds); + + default: + return new MultiplayerPlayer(SelectedItem.Value, userIds); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs new file mode 100644 index 0000000000..9e1a020eca --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs @@ -0,0 +1,84 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A which catches up using rate adjustment. + /// + public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock + { + /// + /// The catch up rate. + /// + public const double CATCHUP_RATE = 2; + + /// + /// The source clock. + /// + public IFrameBasedClock? Source { get; set; } + + public double CurrentTime { get; private set; } + + public bool IsRunning { get; private set; } + + public void Reset() => CurrentTime = 0; + + public void Start() => IsRunning = true; + + public void Stop() => IsRunning = false; + + public bool Seek(double position) => true; + + public void ResetSpeedAdjustments() + { + } + + public double Rate => IsCatchingUp ? CATCHUP_RATE : 1; + + double IAdjustableClock.Rate + { + get => Rate; + set => throw new NotSupportedException(); + } + + double IClock.Rate => Rate; + + public void ProcessFrame() + { + ElapsedFrameTime = 0; + FramesPerSecond = 0; + + if (Source == null) + return; + + Source.ProcessFrame(); + + if (IsRunning) + { + double elapsedSource = Source.ElapsedFrameTime; + double elapsed = elapsedSource * Rate; + + CurrentTime += elapsed; + ElapsedFrameTime = elapsed; + FramesPerSecond = Source.FramesPerSecond; + } + } + + public double ElapsedFrameTime { get; private set; } + + public double FramesPerSecond { get; private set; } + + public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime }; + + public Bindable WaitingOnFrames { get; } = new Bindable(true); + + public bool IsCatchingUp { get; set; } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs new file mode 100644 index 0000000000..efc12eaaa5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.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 System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A which synchronises de-synced player clocks through catchup. + /// + public class CatchUpSyncManager : Component, ISyncManager + { + /// + /// The offset from the master clock to which player clocks should remain within to be considered in-sync. + /// + public const double SYNC_TARGET = 16; + + /// + /// The offset from the master clock at which player clocks begin resynchronising. + /// + public const double MAX_SYNC_OFFSET = 50; + + /// + /// The maximum delay to start gameplay, if any (but not all) player clocks are ready. + /// + public const double MAXIMUM_START_DELAY = 15000; + + /// + /// The master clock which is used to control the timing of all player clocks clocks. + /// + public IAdjustableClock MasterClock { get; } + + /// + /// The player clocks. + /// + private readonly List playerClocks = new List(); + + private bool hasStarted; + private double? firstStartAttemptTime; + + public CatchUpSyncManager(IAdjustableClock master) + { + MasterClock = master; + } + + public void AddPlayerClock(ISpectatorPlayerClock clock) => playerClocks.Add(clock); + + public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock); + + protected override void Update() + { + base.Update(); + + if (!attemptStart()) + { + // Ensure all player clocks are stopped until the start succeeds. + foreach (var clock in playerClocks) + clock.Stop(); + return; + } + + updateCatchup(); + updateMasterClock(); + } + + /// + /// Attempts to start playback. Waits for all player clocks to have available frames for up to milliseconds. + /// + /// Whether playback was started and syncing should occur. + private bool attemptStart() + { + if (hasStarted) + return true; + + if (playerClocks.Count == 0) + return false; + + int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value); + + if (readyCount == playerClocks.Count) + return hasStarted = true; + + if (readyCount > 0) + { + firstStartAttemptTime ??= Time.Current; + + if (Time.Current - firstStartAttemptTime > MAXIMUM_START_DELAY) + return hasStarted = true; + } + + return false; + } + + /// + /// Updates the catchup states of all player clocks clocks. + /// + private void updateCatchup() + { + for (int i = 0; i < playerClocks.Count; i++) + { + var clock = playerClocks[i]; + + // How far this player's clock is out of sync, compared to the master clock. + // A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up). + double timeDelta = MasterClock.CurrentTime - clock.CurrentTime; + + // Check that the player clock isn't too far ahead. + // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock. + if (timeDelta < -SYNC_TARGET) + { + clock.Stop(); + continue; + } + + // Make sure the player clock is running if it can. + if (!clock.WaitingOnFrames.Value) + clock.Start(); + + if (clock.IsCatchingUp) + { + // Stop the player clock from catching up if it's within the sync target. + if (timeDelta <= SYNC_TARGET) + clock.IsCatchingUp = false; + } + else + { + // Make the player clock start catching up if it's exceeded the maximum allowable sync offset. + if (timeDelta > MAX_SYNC_OFFSET) + clock.IsCatchingUp = true; + } + } + } + + /// + /// Updates the master clock's running state. + /// + private void updateMasterClock() + { + bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); + + if (MasterClock.IsRunning != anyInSync) + { + if (anyInSync) + MasterClock.Start(); + else + MasterClock.Stop(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs new file mode 100644 index 0000000000..1a5231e602 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A clock which is used by s and managed by an . + /// + public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock + { + /// + /// Whether this clock is waiting on frames to continue playback. + /// + Bindable WaitingOnFrames { get; } + + /// + /// Whether this clock is resynchronising to the master clock. + /// + bool IsCatchingUp { get; set; } + + /// + /// The source clock + /// + IFrameBasedClock Source { set; } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs new file mode 100644 index 0000000000..bd698108f6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// Manages the synchronisation between one or more s in relation to a master clock. + /// + public interface ISyncManager + { + /// + /// The master clock which player clocks should synchronise to. + /// + IAdjustableClock MasterClock { get; } + + /// + /// Adds an to manage. + /// + /// The to add. + void AddPlayerClock(ISpectatorPlayerClock clock); + + /// + /// Removes an , stopping it from being managed by this . + /// + /// The to remove. + void RemovePlayerClock(ISpectatorPlayerClock clock); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs similarity index 92% rename from osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSpectatorLeaderboard.cs rename to osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index 1b9e2bda2d..ab3ead68b5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -9,9 +9,9 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { - public class MultiplayerSpectatorLeaderboard : MultiplayerGameplayLeaderboard + public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard { - public MultiplayerSpectatorLeaderboard(ScoreProcessor scoreProcessor, int[] userIds) + public MultiSpectatorLeaderboard([NotNull] ScoreProcessor scoreProcessor, int[] userIds) : base(scoreProcessor, userIds) { } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs new file mode 100644 index 0000000000..0fe9e01d9d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A single spectated player within a . + /// + public class MultiSpectatorPlayer : SpectatorPlayer + { + private readonly Bindable waitingOnFrames = new Bindable(true); + private readonly Score score; + private readonly ISpectatorPlayerClock spectatorPlayerClock; + + /// + /// Creates a new . + /// + /// The score containing the player's replay. + /// The clock controlling the gameplay running state. + public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) + : base(score) + { + this.score = score; + this.spectatorPlayerClock = spectatorPlayerClock; + } + + [BackgroundDependencyLoader] + private void load() + { + spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // This is required because the frame stable clock is set to WaitingOnFrames = false for one frame. + waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || score.Replay.Frames.Count == 0; + } + + protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) + => new SpectatorGameplayClockContainer(spectatorPlayerClock); + + private class SpectatorGameplayClockContainer : GameplayClockContainer + { + public SpectatorGameplayClockContainer([NotNull] IClock sourceClock) + : base(sourceClock) + { + } + + protected override void Update() + { + // 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(); + else + Stop(); + + base.Update(); + } + + protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs new file mode 100644 index 0000000000..5a1d28e9c4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Game.Scoring; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// Used to load a single in a . + /// + public class MultiSpectatorPlayerLoader : SpectatorPlayerLoader + { + public MultiSpectatorPlayerLoader([NotNull] Score score, [NotNull] Func createPlayer) + : base(score, createPlayer) + { + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + } + + protected override void LogoExiting(OsuLogo logo) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs new file mode 100644 index 0000000000..8c7b7bab01 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Screens.Play; +using osu.Game.Screens.Spectate; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A that spectates multiple users in a match. + /// + public class MultiSpectatorScreen : SpectatorScreen + { + // Isolates beatmap/ruleset to this screen. + public override bool DisallowExternalBeatmapRulesetChanges => true; + + /// + /// Whether all spectating players have finished loading. + /// + public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true); + + [Resolved] + private SpectatorStreamingClient spectatorClient { get; set; } + + [Resolved] + private StatefulMultiplayerClient multiplayerClient { get; set; } + + private readonly PlayerArea[] instances; + private MasterGameplayClockContainer masterClockContainer; + private ISyncManager syncManager; + private PlayerGrid grid; + private MultiSpectatorLeaderboard leaderboard; + private PlayerArea currentAudioSource; + + /// + /// Creates a new . + /// + /// The players to spectate. + public MultiSpectatorScreen(int[] userIds) + : base(userIds.Take(PlayerGrid.MAX_PLAYERS).ToArray()) + { + instances = new PlayerArea[UserIds.Count]; + } + + [BackgroundDependencyLoader] + private void load() + { + Container leaderboardContainer; + masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0); + + InternalChildren = new[] + { + (Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)), + masterClockContainer.WithChild(new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + leaderboardContainer = new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X + }, + grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + } + } + }) + }; + + for (int i = 0; i < UserIds.Count; i++) + { + grid.Add(instances[i] = new PlayerArea(UserIds[i], masterClockContainer.GameplayClock)); + syncManager.AddPlayerClock(instances[i].GameplayClock); + } + + // Todo: This is not quite correct - it should be per-user to adjust for other mod combinations. + var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor(); + scoreProcessor.ApplyBeatmap(playableBeatmap); + + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, UserIds.ToArray()) + { + Expanded = { Value = true }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, leaderboardContainer.Add); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + masterClockContainer.Stop(); + masterClockContainer.Reset(); + } + + protected override void Update() + { + base.Update(); + + if (!isCandidateAudioSource(currentAudioSource?.GameplayClock)) + { + currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock)) + .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime)) + .FirstOrDefault(); + + foreach (var instance in instances) + instance.Mute = instance != currentAudioSource; + } + } + + private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) + => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; + + protected override void OnUserStateChanged(int userId, SpectatorState spectatorState) + { + } + + protected override void StartGameplay(int userId, GameplayState gameplayState) + { + var instance = instances.Single(i => i.UserId == userId); + + instance.LoadScore(gameplayState.Score); + + syncManager.AddPlayerClock(instance.GameplayClock); + leaderboard.AddClock(instance.UserId, instance.GameplayClock); + } + + protected override void EndGameplay(int userId) + { + RemoveUser(userId); + leaderboard.RemoveClock(userId); + } + + public override bool OnBackButton() + { + // On a manual exit, set the player state back to idle. + multiplayerClient.ChangeState(MultiplayerUserState.Idle); + return base.OnBackButton(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs new file mode 100644 index 0000000000..fe79e5db72 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// Provides an area for and manages the hierarchy of a spectated player within a . + /// + public class PlayerArea : CompositeDrawable + { + /// + /// Whether a is loaded in the area. + /// + public bool PlayerLoaded => stack?.CurrentScreen is Player; + + /// + /// The user id this corresponds to. + /// + public readonly int UserId; + + /// + /// The used to control the gameplay running state of a loaded . + /// + [NotNull] + public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock(); + + /// + /// The currently-loaded score. + /// + [CanBeNull] + public Score Score { get; private set; } + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private readonly BindableDouble volumeAdjustment = new BindableDouble(); + private readonly Container gameplayContent; + private readonly LoadingLayer loadingLayer; + private OsuScreenStack stack; + + public PlayerArea(int userId, IFrameBasedClock masterClock) + { + UserId = userId; + + RelativeSizeAxes = Axes.Both; + Masking = true; + + AudioContainer audioContainer; + InternalChildren = new Drawable[] + { + audioContainer = new AudioContainer + { + RelativeSizeAxes = Axes.Both, + Child = gameplayContent = new DrawSizePreservingFillContainer { RelativeSizeAxes = Axes.Both }, + }, + loadingLayer = new LoadingLayer(true) { State = { Value = Visibility.Visible } } + }; + + audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + + GameplayClock.Source = masterClock; + } + + public void LoadScore([NotNull] Score score) + { + if (Score != null) + throw new InvalidOperationException($"Cannot load a new score on a {nameof(PlayerArea)} that has an existing score."); + + Score = score; + + gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.Beatmap), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + { + RelativeSizeAxes = Axes.Both, + Child = stack = new OsuScreenStack() + }; + + stack.Push(new MultiSpectatorPlayerLoader(Score, () => new MultiSpectatorPlayer(Score, GameplayClock))); + loadingLayer.Hide(); + } + + private bool mute = true; + + public bool Mute + { + get => mute; + set + { + mute = value; + volumeAdjustment.Value = value ? 0 : 1; + } + } + + // Player interferes with global input, so disable input for now. + public override bool PropagatePositionalInputSubTree => false; + public override bool PropagateNonPositionalInputSubTree => false; + + /// + /// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings). + /// + private class PlayerIsolationContainer : Container + { + [Cached] + private readonly Bindable ruleset = new Bindable(); + + [Cached] + private readonly Bindable beatmap = new Bindable(); + + [Cached] + private readonly Bindable> mods = new Bindable>(); + + public PlayerIsolationContainer(WorkingBeatmap beatmap, RulesetInfo ruleset, IReadOnlyList mods) + { + this.beatmap.Value = beatmap; + this.ruleset.Value = ruleset; + this.mods.Value = mods; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(ruleset.BeginLease(false)); + dependencies.CacheAs(beatmap.BeginLease(false)); + dependencies.CacheAs(mods.BeginLease(false)); + return dependencies; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs index 830378f129..6638d47dca 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs @@ -15,6 +15,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public partial class PlayerGrid : CompositeDrawable { + /// + /// A temporary limitation on the number of players, because only layouts up to 16 players are supported for a single screen. + /// Todo: Can be removed in the future with scrolling support + performance improvements. + /// + public const int MAX_PLAYERS = 16; + private const float player_spacing = 5; /// @@ -58,11 +64,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// Adds a new cell with content to this grid. /// /// The content the cell should contain. - /// If more than 16 cells are added. + /// If more than cells are added. public void Add(Drawable content) { - if (cellContainer.Count == 16) - throw new InvalidOperationException("Only 16 cells are supported."); + if (cellContainer.Count == MAX_PLAYERS) + throw new InvalidOperationException($"Only {MAX_PLAYERS} cells are supported."); int index = cellContainer.Count; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 6542d01e64..11bc55823f 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -218,10 +218,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Drawable[] { - new Footer - { - OnStart = onStart, - } + new Footer { OnStart = StartPlay } } }, RowDimensions = new[] @@ -274,9 +271,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, true); } - private void onStart() => StartPlay(() => new PlaylistsPlayer(SelectedItem.Value) + protected override Screen CreateGameplayScreen() => new PlaylistsPlayer(SelectedItem.Value) { Exited = () => leaderboard.RefreshScores() - }); + }; } } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 1c8a3e51ac..f791da80c8 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +13,7 @@ namespace osu.Game.Screens.Play /// /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// - public abstract class GameplayClockContainer : Container + public abstract class GameplayClockContainer : Container, IAdjustableClock { /// /// The final clock which is exposed to gameplay components. @@ -157,5 +158,33 @@ namespace osu.Game.Screens.Play /// The providing the source time. /// The final . protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source); + + #region IAdjustableClock + + bool IAdjustableClock.Seek(double position) + { + Seek(position); + return true; + } + + void IAdjustableClock.Reset() => Reset(); + + public void ResetSpeedAdjustments() + { + } + + double IAdjustableClock.Rate + { + get => GameplayClock.Rate; + set => throw new NotSupportedException(); + } + + double IClock.Rate => GameplayClock.Rate; + + public double CurrentTime => GameplayClock.CurrentTime; + + public bool IsRunning => GameplayClock.IsRunning; + + #endregion } } diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index e4c865803d..45ba05e036 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -2,23 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Skinning; -using osuTK; namespace osu.Game.Screens.Play.HUD { - public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableComponent + public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - private readonly Vector2 offset = new Vector2(-20, 5); - - public DefaultAccuracyCounter() - { - Origin = Anchor.TopRight; - Anchor = Anchor.TopRight; - } - [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } @@ -27,17 +17,5 @@ namespace osu.Game.Screens.Play.HUD { Colour = colours.BlueLighter; } - - protected override void Update() - { - base.Update(); - - if (hud?.ScoreCounter.Drawable is DefaultScoreCounter score) - { - // for now align with the score counter. eventually this will be user customisable. - Anchor = Anchor.TopLeft; - Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopLeft) + offset; - } - } } } diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 375ff293aa..c4575c5ad0 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -9,14 +9,11 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; -using osuTK; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, ISkinnableComponent + public class DefaultComboCounter : RollingCounter, ISkinnableDrawable { - private readonly Vector2 offset = new Vector2(20, 5); - [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } @@ -32,17 +29,6 @@ namespace osu.Game.Screens.Play.HUD Current.BindTo(scoreProcessor.Combo); } - protected override void Update() - { - base.Update(); - - if (hud?.ScoreCounter.Drawable is DefaultScoreCounter score) - { - // for now align with the score counter. eventually this will be user customisable. - Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopRight) + offset; - } - } - protected override string FormatCount(int count) { return $@"{count}x"; diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index 241777244b..ed297f0ffc 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -17,7 +17,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableComponent + public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableDrawable { /// /// The base opacity of the glow. diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 8e37797446..16e3642181 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -8,7 +8,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableComponent + public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable { public DefaultScoreCounter() : base(6) @@ -24,12 +24,6 @@ namespace osu.Game.Screens.Play.HUD private void load(OsuColour colours) { Colour = colours.BlueLighter; - - // todo: check if default once health display is skinnable - hud?.ShowHealthbar.BindValueChanged(healthBar => - { - this.MoveToY(healthBar.NewValue ? 30 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING); - }, true); } } } diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index b970ecd1c7..1f0fafa636 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -16,6 +17,8 @@ namespace osu.Game.Screens.Play.HUD /// public abstract class HealthDisplay : CompositeDrawable { + private readonly Bindable showHealthbar = new Bindable(true); + [Resolved] protected HealthProcessor HealthProcessor { get; private set; } @@ -29,12 +32,21 @@ namespace osu.Game.Screens.Play.HUD { } - [BackgroundDependencyLoader] - private void load() - { - Current.BindTo(HealthProcessor.Health); + [Resolved(canBeNull: true)] + private HUDOverlay hudOverlay { get; set; } + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindTo(HealthProcessor.Health); HealthProcessor.NewJudgement += onNewJudgement; + + if (hudOverlay != null) + showHealthbar.BindTo(hudOverlay.ShowHealthbar); + + // this probably shouldn't be operating on `this.` + showHealthbar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); } private void onNewJudgement(JudgementResult judgement) diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 2e84c9c97d..0e147f9238 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -13,13 +13,12 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { - public class BarHitErrorMeter : HitErrorMeter, ISkinnableComponent + public class BarHitErrorMeter : HitErrorMeter { private readonly Anchor alignment; diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 73305ac93e..d64513d41e 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -15,7 +15,7 @@ namespace osu.Game.Screens.Play.HUD /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. /// - public class LegacyComboCounter : CompositeDrawable, ISkinnableComponent + public class LegacyComboCounter : CompositeDrawable, ISkinnableDrawable { public Bindable Current { get; } = new BindableInt { MinValue = 0, }; @@ -84,13 +84,13 @@ namespace osu.Game.Screens.Play.HUD { InternalChildren = new[] { - popOutCount = new LegacySpriteText(skin, LegacyFont.Combo) + popOutCount = new LegacySpriteText(LegacyFont.Combo) { Alpha = 0, Margin = new MarginPadding(0.05f), Blending = BlendingParameters.Additive, }, - displayedCountSpriteText = new LegacySpriteText(skin, LegacyFont.Combo) + displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo) { Alpha = 0, }, diff --git a/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs deleted file mode 100644 index fcb8fca35d..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableAccuracyCounter : SkinnableDrawable - { - public SkinnableAccuracyCounter() - : base(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter), _ => new DefaultAccuracyCounter()) - { - CentreComponent = false; - } - } -} diff --git a/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs deleted file mode 100644 index c62f1460c9..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableComboCounter : SkinnableDrawable - { - public SkinnableComboCounter() - : base(new HUDSkinComponent(HUDSkinComponents.ComboCounter), skinComponent => new DefaultComboCounter()) - { - CentreComponent = false; - } - } -} diff --git a/osu.Game/Screens/Play/HUD/SkinnableHealthDisplay.cs b/osu.Game/Screens/Play/HUD/SkinnableHealthDisplay.cs deleted file mode 100644 index 3ba6a33276..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableHealthDisplay.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableHealthDisplay : SkinnableDrawable - { - public SkinnableHealthDisplay() - : base(new HUDSkinComponent(HUDSkinComponents.HealthDisplay), _ => new DefaultHealthDisplay()) - { - CentreComponent = false; - } - } -} diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs new file mode 100644 index 0000000000..e08044b14c --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -0,0 +1,74 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Extensions; +using osu.Game.IO.Serialization; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// Serialised information governing custom changes to an . + /// + [Serializable] + public class SkinnableInfo : IJsonSerializable + { + public Type Type { get; set; } + + public Vector2 Position { get; set; } + + public float Rotation { get; set; } + + public Vector2 Scale { get; set; } + + public Anchor Anchor { get; set; } + + public Anchor Origin { get; set; } + + public List Children { get; } = new List(); + + [JsonConstructor] + public SkinnableInfo() + { + } + + /// + /// Construct a new instance populating all attributes from the provided drawable. + /// + /// The drawable which attributes should be sourced from. + public SkinnableInfo(Drawable component) + { + Type = component.GetType(); + + Position = component.Position; + Rotation = component.Rotation; + Scale = component.Scale; + Anchor = component.Anchor; + Origin = component.Origin; + + if (component is Container container) + { + foreach (var child in container.OfType().OfType()) + Children.Add(child.CreateSkinnableInfo()); + } + } + + /// + /// Construct an instance of the drawable with all attributes applied. + /// + /// The new instance. + public Drawable CreateInstance() + { + Drawable d = (Drawable)Activator.CreateInstance(Type); + d.ApplySkinnableInfo(this); + return d; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs deleted file mode 100644 index cc9a712e97..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableScoreCounter : SkinnableDrawable - { - public SkinnableScoreCounter() - : base(new HUDSkinComponent(HUDSkinComponents.ScoreCounter), _ => new DefaultScoreCounter()) - { - CentreComponent = false; - } - } -} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 6ddaa338cc..a10e91dae8 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -22,7 +24,7 @@ using osuTK; namespace osu.Game.Screens.Play { [Cached] - public class HUDOverlay : Container, IKeyBindingHandler, IDefaultSkinnableTarget + public class HUDOverlay : Container, IKeyBindingHandler { public const float FADE_DURATION = 300; @@ -34,9 +36,6 @@ namespace osu.Game.Screens.Play public float TopScoringElementsHeight { get; private set; } public readonly KeyCounterDisplay KeyCounter; - public readonly SkinnableScoreCounter ScoreCounter; - public readonly SkinnableAccuracyCounter AccuracyCounter; - public readonly SkinnableHealthDisplay HealthDisplay; public readonly SongProgress Progress; public readonly ModDisplay ModDisplay; public readonly HoldForMenuButton HoldToQuit; @@ -69,6 +68,8 @@ namespace osu.Game.Screens.Play private bool holdingForHUD; + private readonly SkinnableTargetContainer mainComponents; + private IEnumerable hideTargets => new Drawable[] { visibilityContainer, KeyCounter, topRightElements }; public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods) @@ -96,11 +97,19 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - HealthDisplay = CreateHealthDisplay(), - AccuracyCounter = CreateAccuracyCounter(), - ScoreCounter = CreateScoreCounter(), - CreateComboCounter(), - CreateHitErrorDisplayOverlay(), + mainComponents = new SkinnableTargetContainer(SkinnableTarget.MainHUDComponents) + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + // still need to be migrated; a bit more involved. + new HitErrorDisplay(this.drawableRuleset?.FirstAvailableHitWindows), + } + }, } }, }, @@ -185,7 +194,6 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - ShowHealthbar.BindValueChanged(healthBar => HealthDisplay.FadeTo(healthBar.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING), true); ShowHud.BindValueChanged(visible => hideTargets.ForEach(d => d.FadeTo(visible.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING))); IsBreakTime.BindValueChanged(_ => updateVisibility()); @@ -198,11 +206,25 @@ namespace osu.Game.Screens.Play { base.Update(); - // HACK: for now align with the accuracy counter. - // this is done for the sake of hacky legacy skins which extend the health bar to take up the full screen area. - // it only works with the default skin due to padding offsetting it *just enough* to coexist. - topRightElements.Y = TopScoringElementsHeight = ToLocalSpace(AccuracyCounter.Drawable.ScreenSpaceDrawQuad.BottomRight).Y; + Vector2 lowestScreenSpace = Vector2.Zero; + // LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes. + foreach (var element in mainComponents.Components.Cast()) + { + // for now align top-right components with the bottom-edge of the lowest top-anchored hud element. + if (!element.Anchor.HasFlagFast(Anchor.TopRight) && !element.RelativeSizeAxes.HasFlagFast(Axes.X)) + continue; + + // health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area. + if (element is LegacyHealthDisplay) + continue; + + var bottomRight = element.ScreenSpaceDrawQuad.BottomRight; + if (bottomRight.Y > lowestScreenSpace.Y) + lowestScreenSpace = bottomRight; + } + + topRightElements.Y = TopScoringElementsHeight = ToLocalSpace(lowestScreenSpace).Y; bottomRightElements.Y = -Progress.Height; } @@ -263,48 +285,38 @@ namespace osu.Game.Screens.Play Progress.BindDrawableRuleset(drawableRuleset); } - protected SkinnableAccuracyCounter CreateAccuracyCounter() => new SkinnableAccuracyCounter(); - - protected SkinnableScoreCounter CreateScoreCounter() => new SkinnableScoreCounter(); - - protected SkinnableComboCounter CreateComboCounter() => new SkinnableComboCounter(); - - protected SkinnableHealthDisplay CreateHealthDisplay() => new SkinnableHealthDisplay(); - - protected virtual FailingLayer CreateFailingLayer() => new FailingLayer + protected FailingLayer CreateFailingLayer() => new FailingLayer { ShowHealth = { BindTarget = ShowHealthbar } }; - protected virtual KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay + protected KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }; - protected virtual SongProgress CreateProgress() => new SongProgress + protected SongProgress CreateProgress() => new SongProgress { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, }; - protected virtual HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton + protected HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }; - protected virtual ModDisplay CreateModsContainer() => new ModDisplay + protected ModDisplay CreateModsContainer() => new ModDisplay { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, }; - protected virtual HitErrorDisplay CreateHitErrorDisplayOverlay() => new HitErrorDisplay(drawableRuleset?.FirstAvailableHitWindows); - - protected virtual PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); + protected PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); public bool OnPressed(GlobalAction action) { diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index d85f3538e4..4a0787bfae 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -14,7 +14,6 @@ using osu.Framework.Timing; using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; -using osu.Game.Skinning; namespace osu.Game.Screens.Play { @@ -181,12 +180,12 @@ namespace osu.Game.Screens.Play info.TransformTo(nameof(info.Margin), new MarginPadding { Bottom = finalMargin }, transition_duration, Easing.In); } - public class SongProgressDisplay : Container, ISkinnableComponent + public class SongProgressDisplay : Container { public SongProgressDisplay() { // TODO: move actual implementation into this. - // exists for skin customisation purposes. + // exists for skin customisation purposes (interface should be added to this container). Masking = true; RelativeSizeAxes = Axes.Both; diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs index 580af81166..bdd23962dc 100644 --- a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs +++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs @@ -12,7 +12,12 @@ namespace osu.Game.Screens.Play public readonly ScoreInfo Score; public SpectatorPlayerLoader(Score score) - : base(() => new SpectatorPlayer(score)) + : this(score, () => new SpectatorPlayer(score)) + { + } + + public SpectatorPlayerLoader(Score score, Func createPlayer) + : base(createPlayer) { if (score.Replay == null) throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score)); diff --git a/osu.Game/Screens/ScreenWhiteBox.cs b/osu.Game/Screens/ScreenWhiteBox.cs index 3d8fd5dad7..cf0c183766 100644 --- a/osu.Game/Screens/ScreenWhiteBox.cs +++ b/osu.Game/Screens/ScreenWhiteBox.cs @@ -87,9 +87,9 @@ namespace osu.Game.Screens private static Color4 getColourFor(object type) { int hash = type.GetHashCode(); - byte r = (byte)Math.Clamp(((hash & 0xFF0000) >> 16) * 0.8f, 20, 255); - byte g = (byte)Math.Clamp(((hash & 0x00FF00) >> 8) * 0.8f, 20, 255); - byte b = (byte)Math.Clamp((hash & 0x0000FF) * 0.8f, 20, 255); + byte r = (byte)Math.Clamp(((hash & 0xFF0000) >> 16) * 2, 128, 255); + byte g = (byte)Math.Clamp(((hash & 0x00FF00) >> 8) * 2, 128, 255); + byte b = (byte)Math.Clamp((hash & 0x0000FF) * 2, 128, 255); return new Color4(r, g, b, 255); } @@ -109,10 +109,10 @@ namespace osu.Game.Screens private readonly Container boxContainer; - public UnderConstructionMessage(string name) + public UnderConstructionMessage(string name, string description = "is not yet ready for use!") { - RelativeSizeAxes = Axes.Both; - Size = new Vector2(0.3f); + AutoSizeAxes = Axes.Both; + Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -124,7 +124,7 @@ namespace osu.Game.Screens { CornerRadius = 20, Masking = true, - RelativeSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] @@ -133,15 +133,15 @@ namespace osu.Game.Screens { RelativeSizeAxes = Axes.Both, - Colour = colour, - Alpha = 0.2f, - Blending = BlendingParameters.Additive, + Colour = colour.Darken(0.8f), + Alpha = 0.8f, }, TextContainer = new FillFlowContainer { AutoSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, + Padding = new MarginPadding(20), Direction = FillDirection.Vertical, Children = new Drawable[] { @@ -157,14 +157,14 @@ namespace osu.Game.Screens Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = name, - Colour = colour.Lighten(0.8f), + Colour = colour, Font = OsuFont.GetFont(size: 36), }, new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "is not yet ready for use!", + Text = description, Font = OsuFont.GetFont(size: 20), }, new OsuSpriteText diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index ed01d56801..bcebd51954 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -26,7 +26,9 @@ namespace osu.Game.Screens.Spectate /// public abstract class SpectatorScreen : OsuScreen { - private readonly int[] userIds; + protected IReadOnlyList UserIds => userIds; + + private readonly List userIds = new List(); [Resolved] private BeatmapManager beatmaps { get; set; } @@ -54,7 +56,7 @@ namespace osu.Game.Screens.Spectate /// The users to spectate. protected SpectatorScreen(params int[] userIds) { - this.userIds = userIds; + this.userIds.AddRange(userIds); } protected override void LoadComplete() @@ -80,20 +82,18 @@ namespace osu.Game.Screens.Spectate private Task populateAllUsers() { - var userLookupTasks = new Task[userIds.Length]; + var userLookupTasks = new List(); - for (int i = 0; i < userIds.Length; i++) + foreach (var u in userIds) { - var userId = userIds[i]; - - userLookupTasks[i] = userLookupCache.GetUserAsync(userId).ContinueWith(task => + userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task => { if (!task.IsCompletedSuccessfully) return; lock (stateLock) - userMap[userId] = task.Result; - }); + userMap[u] = task.Result; + })); } return Task.WhenAll(userLookupTasks); @@ -239,6 +239,23 @@ namespace osu.Game.Screens.Spectate /// The user to end gameplay for. protected abstract void EndGameplay(int userId); + /// + /// Stops spectating a user. + /// + /// The user to stop spectating. + protected void RemoveUser(int userId) + { + lock (stateLock) + { + userFinishedPlaying(userId, null); + + userIds.Remove(userId); + userMap.Remove(userId); + + spectatorClient.StopWatchingUser(userId); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index 4027cc650d..f30130b1fb 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using JetBrains.Annotations; using osu.Framework.IO.Stores; +using osu.Game.Extensions; using osu.Game.IO; using osuTK.Graphics; @@ -10,7 +12,13 @@ namespace osu.Game.Skinning public class DefaultLegacySkin : LegacySkin { public DefaultLegacySkin(IResourceStore storage, IStorageResourceProvider resources) - : base(Info, storage, resources, string.Empty) + : this(Info, storage, resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public DefaultLegacySkin(SkinInfo skin, IResourceStore storage, IStorageResourceProvider resources) + : base(skin, storage, resources, string.Empty) { Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255); Configuration.AddComboColours( @@ -27,7 +35,8 @@ namespace osu.Game.Skinning { ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. Name = "osu!classic", - Creator = "team osu!" + Creator = "team osu!", + InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() }; } } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 0b3f5f3cde..9b4e32531a 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -2,30 +2,122 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Screens.Play.HUD; +using osuTK; using osuTK.Graphics; namespace osu.Game.Skinning { public class DefaultSkin : Skin { - public DefaultSkin() - : base(SkinInfo.Default) + public DefaultSkin(IStorageResourceProvider resources) + : this(SkinInfo.Default, resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public DefaultSkin(SkinInfo skin, IStorageResourceProvider resources) + : base(skin, resources) { Configuration = new DefaultSkinConfiguration(); } - public override Drawable GetDrawableComponent(ISkinComponent component) => null; - public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public override ISample GetSample(ISampleInfo sampleInfo) => null; + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (base.GetDrawableComponent(component) is Drawable c) + return c; + + switch (component) + { + case SkinnableTargetComponent target: + switch (target.Target) + { + case SkinnableTarget.MainHUDComponents: + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => + { + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + var combo = container.OfType().FirstOrDefault(); + + if (score != null) + { + score.Anchor = Anchor.TopCentre; + score.Origin = Anchor.TopCentre; + + // elements default to beneath the health bar + const float vertical_offset = 30; + + const float horizontal_padding = 20; + + score.Position = new Vector2(0, vertical_offset); + + if (accuracy != null) + { + accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); + accuracy.Origin = Anchor.TopRight; + accuracy.Anchor = Anchor.TopCentre; + } + + if (combo != null) + { + combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); + combo.Anchor = Anchor.TopCentre; + } + } + }) + { + Children = new[] + { + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ComboCounter)), + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreCounter)), + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter)), + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.HealthDisplay)), + } + }; + + return skinnableTargetWrapper; + } + + break; + + case HUDSkinComponent hudComponent: + { + switch (hudComponent.Component) + { + case HUDSkinComponents.ComboCounter: + return new DefaultComboCounter(); + + case HUDSkinComponents.ScoreCounter: + return new DefaultScoreCounter(); + + case HUDSkinComponents.AccuracyCounter: + return new DefaultAccuracyCounter(); + + case HUDSkinComponents.HealthDisplay: + return new DefaultHealthDisplay(); + } + + break; + } + } + + return null; + } + public override IBindable GetConfig(TLookup lookup) { switch (lookup) diff --git a/osu.Game/Skinning/Editor/SkinBlueprint.cs b/osu.Game/Skinning/Editor/SkinBlueprint.cs index b2a1b1c9d8..f3b4b09a79 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprint.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprint.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Skinning.Editor { - public class SkinBlueprint : SelectionBlueprint + public class SkinBlueprint : SelectionBlueprint { private Container box; @@ -24,17 +24,12 @@ namespace osu.Game.Skinning.Editor private Drawable drawable => (Drawable)Item; - /// - /// Whether the blueprint should be shown even when the is not alive. - /// - protected virtual bool AlwaysShowWhenSelected => true; - protected override bool ShouldBeAlive => drawable.IsAlive && Item.IsPresent; [Resolved] private OsuColour colours { get; set; } - public SkinBlueprint(ISkinnableComponent component) + public SkinBlueprint(ISkinnableDrawable component) : base(component) { } diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs index 35e93d9aff..c0cc2ab40e 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -1,42 +1,97 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Rulesets.Edit; +using osu.Game.Screens; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Skinning.Editor { - public class SkinBlueprintContainer : BlueprintContainer + public class SkinBlueprintContainer : BlueprintContainer { private readonly Drawable target; + private readonly List> targetComponents = new List>(); + public SkinBlueprintContainer(Drawable target) { this.target = target; } + [BackgroundDependencyLoader(true)] + private void load(SkinEditor editor) + { + SelectedItems.BindTo(editor.SelectedComponents); + } + protected override void LoadComplete() { base.LoadComplete(); - checkForComponents(); + // track each target container on the current screen. + var targetContainers = target.ChildrenOfType().ToArray(); + + if (targetContainers.Length == 0) + { + var targetScreen = target.ChildrenOfType().LastOrDefault()?.GetType().Name ?? "this screen"; + + AddInternal(new ScreenWhiteBox.UnderConstructionMessage(targetScreen, "doesn't support skin customisation just yet.")); + return; + } + + foreach (var targetContainer in targetContainers) + { + var bindableList = new BindableList { BindTarget = targetContainer.Components }; + bindableList.BindCollectionChanged(componentsChanged, true); + + targetComponents.Add(bindableList); + } } - private void checkForComponents() + private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e) { - foreach (var c in target.ChildrenOfType().ToArray()) AddBlueprintFor(c); + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var item in e.NewItems.Cast()) + AddBlueprintFor(item); + break; - // We'd hope to eventually be running this in a more sensible way, but this handles situations where new drawables become present (ie. during ongoing gameplay) - // or when drawables in the target are loaded asynchronously and may not be immediately available when this BlueprintContainer is loaded. - Scheduler.AddDelayed(checkForComponents, 1000); + case NotifyCollectionChangedAction.Remove: + case NotifyCollectionChangedAction.Reset: + foreach (var item in e.OldItems.Cast()) + RemoveBlueprintFor(item); + break; + + case NotifyCollectionChangedAction.Replace: + foreach (var item in e.OldItems.Cast()) + RemoveBlueprintFor(item); + + foreach (var item in e.NewItems.Cast()) + AddBlueprintFor(item); + break; + } } - protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + protected override void AddBlueprintFor(ISkinnableDrawable item) + { + if (!item.IsEditable) + return; - protected override SelectionBlueprint CreateBlueprintFor(ISkinnableComponent component) + base.AddBlueprintFor(item); + } + + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) => new SkinBlueprint(component); } } diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index a000204062..8536cba139 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -56,7 +56,7 @@ namespace osu.Game.Skinning.Editor Spacing = new Vector2(20) }; - var skinnableTypes = typeof(OsuGame).Assembly.GetTypes().Where(t => typeof(ISkinnableComponent).IsAssignableFrom(t)).ToArray(); + var skinnableTypes = typeof(OsuGame).Assembly.GetTypes().Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)).ToArray(); foreach (var type in skinnableTypes) { @@ -78,6 +78,9 @@ namespace osu.Game.Skinning.Editor Debug.Assert(instance != null); + if (!((ISkinnableDrawable)instance).IsEditable) + return null; + return new ToolboxComponentButton(instance); } catch diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 18a8b220df..6427d6298b 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -11,28 +12,43 @@ using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osuTK; namespace osu.Game.Skinning.Editor { + [Cached(typeof(SkinEditor))] public class SkinEditor : FocusedOverlayContainer { public const double TRANSITION_DURATION = 500; - private readonly Drawable target; - - private OsuTextFlowContainer headerText; + public readonly BindableList SelectedComponents = new BindableList(); protected override bool StartHidden => true; - public SkinEditor(Drawable target) + private readonly Drawable targetScreen; + + private OsuTextFlowContainer headerText; + + private Bindable currentSkin; + + [Resolved] + private SkinManager skins { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + private bool hasBegunMutating; + + public SkinEditor(Drawable targetScreen) { - this.target = target; + this.targetScreen = targetScreen; RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { InternalChild = new OsuContextMenuContainer { @@ -47,37 +63,165 @@ namespace osu.Game.Skinning.Editor Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.X }, - new SkinBlueprintContainer(target), - new SkinComponentToolbox(600) + new GridContainer { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RequestPlacement = placeComponent + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SkinComponentToolbox(600) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RequestPlacement = placeComponent + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new SkinBlueprintContainer(targetScreen), + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Spacing = new Vector2(5), + Padding = new MarginPadding + { + Top = 10, + Left = 10, + }, + Margin = new MarginPadding + { + Right = 10, + Bottom = 10, + }, + Children = new Drawable[] + { + new TriangleButton + { + Text = "Save Changes", + Width = 140, + Action = Save, + }, + new DangerousTriangleButton + { + Text = "Revert to default", + Width = 140, + Action = revert, + }, + } + }, + } + }, + } + } } } }; - - headerText.AddParagraph("Skin editor (preview)", cp => cp.Font = OsuFont.Default.With(size: 24)); - headerText.AddParagraph("This is a preview of what is to come. Changes are lost on changing screens.", cp => - { - cp.Font = OsuFont.Default.With(size: 12); - cp.Colour = colours.Yellow; - }); - } - - private void placeComponent(Type type) - { - var instance = (Drawable)Activator.CreateInstance(type); - - var targetContainer = target.ChildrenOfType().FirstOrDefault(); - - targetContainer?.Add(instance); } protected override void LoadComplete() { base.LoadComplete(); + Show(); + + // as long as the skin editor is loaded, let's make sure we can modify the current skin. + currentSkin = skins.CurrentSkin.GetBoundCopy(); + + // schedule ensures this only happens when the skin editor is visible. + // also avoid some weird endless recursion / bindable feedback loop (something to do with tracking skins across three different bindable types). + // probably something which will be factored out in a future database refactor so not too concerning for now. + currentSkin.BindValueChanged(skin => + { + hasBegunMutating = false; + Scheduler.AddOnce(skinChanged); + }, true); + } + + private void skinChanged() + { + headerText.Clear(); + + headerText.AddParagraph("Skin editor", cp => cp.Font = OsuFont.Default.With(size: 24)); + headerText.NewParagraph(); + headerText.AddText("Currently editing ", cp => + { + cp.Font = OsuFont.Default.With(size: 12); + cp.Colour = colours.Yellow; + }); + + headerText.AddText($"{currentSkin.Value.SkinInfo}", cp => + { + cp.Font = OsuFont.Default.With(size: 12, weight: FontWeight.Bold); + cp.Colour = colours.Yellow; + }); + + skins.EnsureMutableSkin(); + hasBegunMutating = true; + } + + private void placeComponent(Type type) + { + var targetContainer = getTarget(SkinnableTarget.MainHUDComponents); + + if (targetContainer == null) + return; + + if (!(Activator.CreateInstance(type) is ISkinnableDrawable component)) + throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}."); + + var drawableComponent = (Drawable)component; + + // give newly added components a sane starting location. + drawableComponent.Origin = Anchor.TopCentre; + drawableComponent.Anchor = Anchor.TopCentre; + drawableComponent.Y = targetContainer.DrawSize.Y / 2; + + targetContainer.Add(component); + + SelectedComponents.Clear(); + SelectedComponents.Add(component); + } + + private ISkinnableTarget getTarget(SkinnableTarget target) + { + return targetScreen.ChildrenOfType().FirstOrDefault(c => c.Target == target); + } + + private void revert() + { + SkinnableTargetContainer[] targetContainers = targetScreen.ChildrenOfType().ToArray(); + + foreach (var t in targetContainers) + { + currentSkin.Value.ResetDrawableTarget(t); + + // add back default components + getTarget(t.Target).Reload(); + } + } + + public void Save() + { + if (!hasBegunMutating) + return; + + SkinnableTargetContainer[] targetContainers = targetScreen.ChildrenOfType().ToArray(); + + foreach (var t in targetContainers) + currentSkin.Value.UpdateDrawableTarget(t); + + skins.Save(skins.CurrentSkin.Value); } protected override bool OnHover(HoverEvent e) => true; diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index cc989bb459..2d7cae71ff 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -71,7 +71,7 @@ namespace osu.Game.Skinning.Editor target.RelativePositionAxes = Axes.Both; target.ScaleTo(VISIBLE_TARGET_SCALE, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); - target.MoveToX(0.1f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + target.MoveToX(0.095f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); } else { @@ -92,8 +92,10 @@ namespace osu.Game.Skinning.Editor /// public void Reset() { + skinEditor?.Save(); skinEditor?.Hide(); skinEditor?.Expire(); + skinEditor = null; } } diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 3dc0ca340b..485cd3fe93 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Skinning.Editor { - public class SkinSelectionHandler : SelectionHandler + public class SkinSelectionHandler : SelectionHandler { public override bool HandleRotation(float angle) { @@ -51,7 +51,7 @@ namespace osu.Game.Skinning.Editor return true; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { foreach (var c in SelectedBlueprints) { @@ -72,7 +72,7 @@ namespace osu.Game.Skinning.Editor SelectionBox.CanReverse = false; } - protected override void DeleteItems(IEnumerable items) + protected override void DeleteItems(IEnumerable items) { foreach (var i in items) { @@ -81,17 +81,22 @@ namespace osu.Game.Skinning.Editor } } - protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { yield return new OsuMenuItem("Anchor") { - Items = createAnchorItems().ToArray() + Items = createAnchorItems(d => d.Anchor, applyAnchor).ToArray() + }; + + yield return new OsuMenuItem("Origin") + { + Items = createAnchorItems(d => d.Origin, applyOrigin).ToArray() }; foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems() + IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) { var displayableAnchors = new[] { @@ -108,18 +113,36 @@ namespace osu.Game.Skinning.Editor return displayableAnchors.Select(a => { - return new AnchorMenuItem(a, selection, _ => applyAnchor(a)) + return new AnchorMenuItem(a, selection, _ => applyFunction(a)) { - State = { Value = GetStateFromSelection(selection, c => ((Drawable)c.Item).Anchor == a) } + State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) } }; }); } } + private void applyOrigin(Anchor anchor) + { + foreach (var item in SelectedItems) + { + var drawable = (Drawable)item; + + var previousOrigin = drawable.OriginPosition; + drawable.Origin = anchor; + drawable.Position += drawable.OriginPosition - previousOrigin; + } + } + private void applyAnchor(Anchor anchor) { foreach (var item in SelectedItems) - ((Drawable)item).Anchor = anchor; + { + var drawable = (Drawable)item; + + var previousAnchor = drawable.AnchorPosition; + drawable.Anchor = anchor; + drawable.Position -= drawable.AnchorPosition - previousAnchor; + } } private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) @@ -144,7 +167,7 @@ namespace osu.Game.Skinning.Editor public class AnchorMenuItem : TernaryStateMenuItem { - public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action) + public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action) : base(anchor.ToString(), getNextState, MenuItemType.Standard, action) { } diff --git a/osu.Game/Skinning/ISkinnableComponent.cs b/osu.Game/Skinning/ISkinnableDrawable.cs similarity index 64% rename from osu.Game/Skinning/ISkinnableComponent.cs rename to osu.Game/Skinning/ISkinnableDrawable.cs index f6b0a182b4..d42b6f71b0 100644 --- a/osu.Game/Skinning/ISkinnableComponent.cs +++ b/osu.Game/Skinning/ISkinnableDrawable.cs @@ -8,7 +8,11 @@ namespace osu.Game.Skinning /// /// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications. /// - public interface ISkinnableComponent : IDrawable + public interface ISkinnableDrawable : IDrawable { + /// + /// Whether this component should be editable by an end user. + /// + bool IsEditable => true; } } diff --git a/osu.Game/Skinning/ISkinnableTarget.cs b/osu.Game/Skinning/ISkinnableTarget.cs index 607e89fdec..65b8ba7b17 100644 --- a/osu.Game/Skinning/ISkinnableTarget.cs +++ b/osu.Game/Skinning/ISkinnableTarget.cs @@ -1,15 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Game.Extensions; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { /// - /// Denotes a container which can house s. + /// Denotes a container which can house s. /// - public interface ISkinnableTarget : IContainerCollection + public interface ISkinnableTarget : IDrawable { + /// + /// The definition of this target. + /// + SkinnableTarget Target { get; } + + /// + /// A bindable list of components which are being tracked by this skinnable target. + /// + IBindableList Components { get; } + + /// + /// Serialise all children as . + /// + /// The serialised content. + IEnumerable CreateSkinnableInfo() => Components.Select(d => ((Drawable)d).CreateSkinnableInfo()); + + /// + /// Reload this target from the current skin. + /// + void Reload(); + + /// + /// Add the provided item to this target. + /// + void Add(ISkinnableDrawable drawable); } } diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index 3efcd5555e..16562d9571 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -10,39 +10,24 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableComponent + public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - private readonly ISkin skin; - - public LegacyAccuracyCounter(ISkin skin) + public LegacyAccuracyCounter() { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; Scale = new Vector2(0.6f); Margin = new MarginPadding(10); - - this.skin = skin; } [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } - protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(skin, LegacyFont.Score) + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score) { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }; - - protected override void Update() - { - base.Update(); - - if (hud?.ScoreCounter.Drawable is LegacyScoreCounter score) - { - // for now align with the score counter. eventually this will be user customisable. - Y = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; - } - } } } diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index c1979efbc2..c601adc3a0 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -16,11 +16,13 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class LegacyHealthDisplay : HealthDisplay + public class LegacyHealthDisplay : HealthDisplay, ISkinnableDrawable { private const double epic_cutoff = 0.5; - private readonly Skin skin; + [Resolved] + private ISkinSource skin { get; set; } + private LegacyHealthPiece fill; private LegacyHealthPiece marker; @@ -28,11 +30,6 @@ namespace osu.Game.Skinning private bool isNewStyle; - public LegacyHealthDisplay(Skin skin) - { - this.skin = skin; - } - [BackgroundDependencyLoader] private void load() { @@ -79,7 +76,7 @@ namespace osu.Game.Skinning protected override void Flash(JudgementResult result) => marker.Flash(result); - private static Texture getTexture(Skin skin, string name) => skin.GetTexture($"scorebar-{name}"); + private static Texture getTexture(ISkinSource skin, string name) => skin.GetTexture($"scorebar-{name}"); private static Color4 getFillColour(double hp) { @@ -98,7 +95,7 @@ namespace osu.Game.Skinning private readonly Texture dangerTexture; private readonly Texture superDangerTexture; - public LegacyOldStyleMarker(Skin skin) + public LegacyOldStyleMarker(ISkinSource skin) { normalTexture = getTexture(skin, "ki"); dangerTexture = getTexture(skin, "kidanger"); @@ -129,9 +126,9 @@ namespace osu.Game.Skinning public class LegacyNewStyleMarker : LegacyMarker { - private readonly Skin skin; + private readonly ISkinSource skin; - public LegacyNewStyleMarker(Skin skin) + public LegacyNewStyleMarker(ISkinSource skin) { this.skin = skin; } @@ -153,7 +150,7 @@ namespace osu.Game.Skinning internal class LegacyOldStyleFill : LegacyHealthPiece { - public LegacyOldStyleFill(Skin skin) + public LegacyOldStyleFill(ISkinSource skin) { // required for sizing correctly.. var firstFrame = getTexture(skin, "colour-0"); @@ -176,7 +173,7 @@ namespace osu.Game.Skinning internal class LegacyNewStyleFill : LegacyHealthPiece { - public LegacyNewStyleFill(Skin skin) + public LegacyNewStyleFill(ISkinSource skin) { InternalChild = new Sprite { diff --git a/osu.Game/Skinning/LegacyRollingCounter.cs b/osu.Game/Skinning/LegacyRollingCounter.cs index 0261db0e64..b531ae1e6f 100644 --- a/osu.Game/Skinning/LegacyRollingCounter.cs +++ b/osu.Game/Skinning/LegacyRollingCounter.cs @@ -12,7 +12,6 @@ namespace osu.Game.Skinning /// public class LegacyRollingCounter : RollingCounter { - private readonly ISkin skin; private readonly LegacyFont font; protected override bool IsRollingProportional => true; @@ -20,11 +19,9 @@ namespace osu.Game.Skinning /// /// Creates a new . /// - /// The from which to get counter number sprites. /// The legacy font to use for the counter. - public LegacyRollingCounter(ISkin skin, LegacyFont font) + public LegacyRollingCounter(LegacyFont font) { - this.skin = skin; this.font = font; } @@ -33,6 +30,6 @@ namespace osu.Game.Skinning return Math.Abs(newValue - currentValue) * 75.0; } - protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(skin, font); + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(font); } } diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index ecb907e601..64ea03d59c 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -8,26 +8,22 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableComponent + public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableDrawable { - private readonly ISkin skin; - protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - public LegacyScoreCounter(ISkin skin) + public LegacyScoreCounter() : base(6) { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; - this.skin = skin; - Scale = new Vector2(0.96f); Margin = new MarginPadding(10); } - protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(skin, LegacyFont.Score) + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score) { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index eae3b69233..a6f8f45c0f 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -52,13 +52,14 @@ namespace osu.Game.Skinning private readonly Dictionary maniaConfigurations = new Dictionary(); + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] public LegacySkin(SkinInfo skin, IStorageResourceProvider resources) : this(skin, new LegacySkinResourceStore(skin, resources.Files), resources, "skin.ini") { } protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, string filename) - : base(skin) + : base(skin, resources) { using (var stream = storage?.GetStream(filename)) { @@ -321,8 +322,42 @@ namespace osu.Game.Skinning public override Drawable GetDrawableComponent(ISkinComponent component) { + if (base.GetDrawableComponent(component) is Drawable c) + return c; + switch (component) { + case SkinnableTargetComponent target: + switch (target.Target) + { + case SkinnableTarget.MainHUDComponents: + + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => + { + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + + if (score != null && accuracy != null) + { + accuracy.Y = container.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; + } + }) + { + Children = new[] + { + // TODO: these should fallback to the osu!classic skin. + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ComboCounter)) ?? new DefaultComboCounter(), + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreCounter)) ?? new DefaultScoreCounter(), + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter)) ?? new DefaultAccuracyCounter(), + GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.HealthDisplay)) ?? new DefaultHealthDisplay(), + } + }; + + return skinnableTargetWrapper; + } + + return null; + case HUDSkinComponent hudComponent: { if (!this.HasFont(LegacyFont.Score)) @@ -334,13 +369,13 @@ namespace osu.Game.Skinning return new LegacyComboCounter(); case HUDSkinComponents.ScoreCounter: - return new LegacyScoreCounter(this); + return new LegacyScoreCounter(); case HUDSkinComponents.AccuracyCounter: - return new LegacyAccuracyCounter(this); + return new LegacyAccuracyCounter(); case HUDSkinComponents.HealthDisplay: - return new LegacyHealthDisplay(this); + return new LegacyHealthDisplay(); } return null; diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index c55400e219..7895fcccca 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; +using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Text; using osu.Game.Graphics.Sprites; @@ -9,19 +10,26 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacySpriteText : OsuSpriteText + public sealed class LegacySpriteText : OsuSpriteText { - private readonly LegacyGlyphStore glyphStore; + private readonly LegacyFont font; + + private LegacyGlyphStore glyphStore; protected override char FixedWidthReferenceCharacter => '5'; protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' }; - public LegacySpriteText(ISkin skin, LegacyFont font) + public LegacySpriteText(LegacyFont font) { + this.font = font; Shadow = false; UseFullGlyphHeight = false; + } + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: true); Spacing = new Vector2(-skin.GetFontOverlap(font), 0); diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 6d1bce2cb1..2944c7a8ec 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -2,12 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.IO; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { @@ -17,7 +23,9 @@ namespace osu.Game.Skinning public SkinConfiguration Configuration { get; protected set; } - public abstract Drawable GetDrawableComponent(ISkinComponent componentName); + public IDictionary DrawableComponentInfo => drawableComponentInfo; + + private readonly Dictionary drawableComponentInfo = new Dictionary(); public abstract ISample GetSample(ISampleInfo sampleInfo); @@ -27,9 +35,65 @@ namespace osu.Game.Skinning public abstract IBindable GetConfig(TLookup lookup); - protected Skin(SkinInfo skin) + protected Skin(SkinInfo skin, IStorageResourceProvider resources) { SkinInfo = skin; + + // we may want to move this to some kind of async operation in the future. + foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) + { + string filename = $"{skinnableTarget}.json"; + + // skininfo files may be null for default skin. + var fileInfo = SkinInfo.Files?.FirstOrDefault(f => f.Filename == filename); + + if (fileInfo == null) + continue; + + var bytes = resources?.Files.Get(fileInfo.FileInfo.StoragePath); + + if (bytes == null) + continue; + + string jsonContent = Encoding.UTF8.GetString(bytes); + + DrawableComponentInfo[skinnableTarget] = JsonConvert.DeserializeObject>(jsonContent).ToArray(); + } + } + + /// + /// Remove all stored customisations for the provided target. + /// + /// The target container to reset. + public void ResetDrawableTarget(ISkinnableTarget targetContainer) + { + DrawableComponentInfo.Remove(targetContainer.Target); + } + + /// + /// Update serialised information for the provided target. + /// + /// The target container to serialise to this skin. + public void UpdateDrawableTarget(ISkinnableTarget targetContainer) + { + DrawableComponentInfo[targetContainer.Target] = targetContainer.CreateSkinnableInfo().ToArray(); + } + + public virtual Drawable GetDrawableComponent(ISkinComponent component) + { + switch (component) + { + case SkinnableTargetComponent target: + if (!DrawableComponentInfo.TryGetValue(target.Target, out var skinnableInfo)) + return null; + + return new SkinnableTargetComponentsContainer + { + ChildrenEnumerable = skinnableInfo.Select(i => i.CreateInstance()) + }; + } + + return null; } #region Disposal diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index aaccbefb3d..bc57a8e71c 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; +using osu.Framework.IO.Stores; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO; namespace osu.Game.Skinning { @@ -22,7 +25,22 @@ namespace osu.Game.Skinning public string Creator { get; set; } - public List Files { get; set; } + public string InstantiationInfo { get; set; } + + public virtual Skin CreateInstance(IResourceStore legacyDefaultResources, IStorageResourceProvider resources) + { + var type = string.IsNullOrEmpty(InstantiationInfo) + // handle the case of skins imported before InstantiationInfo was added. + ? typeof(LegacySkin) + : Type.GetType(InstantiationInfo); + + if (type == typeof(DefaultLegacySkin)) + return (Skin)Activator.CreateInstance(type, this, legacyDefaultResources, resources); + + return (Skin)Activator.CreateInstance(type, this, resources); + } + + public List Files { get; set; } = new List(); public List Settings { get; set; } @@ -32,7 +50,8 @@ namespace osu.Game.Skinning { ID = DEFAULT_SKIN, Name = "osu!lazer", - Creator = "team osu!" + Creator = "team osu!", + InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() }; public bool Equals(SkinInfo other) => other != null && ID == other.ID; diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index ac4d63159a..5793edda30 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -6,9 +6,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Linq.Expressions; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -22,6 +24,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; @@ -36,7 +39,7 @@ namespace osu.Game.Skinning private readonly IResourceStore legacyDefaultResources; - public readonly Bindable CurrentSkin = new Bindable(new DefaultSkin()); + public readonly Bindable CurrentSkin = new Bindable(new DefaultSkin(null)); public readonly Bindable CurrentSkinInfo = new Bindable(SkinInfo.Default) { Default = SkinInfo.Default }; public override IEnumerable HandledExtensions => new[] { ".osk" }; @@ -105,7 +108,7 @@ namespace osu.Game.Skinning { // we need to populate early to create a hash based off skin.ini contents if (item.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) - populateMetadata(item); + populateMetadata(item, GetSkin(item)); if (item.Creator != null && item.Creator != unknown_creator_string) { @@ -122,18 +125,20 @@ namespace osu.Game.Skinning { await base.Populate(model, archive, cancellationToken).ConfigureAwait(false); + var instance = GetSkin(model); + + model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); + if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) - populateMetadata(model); + populateMetadata(model, instance); } - private void populateMetadata(SkinInfo item) + private void populateMetadata(SkinInfo item, Skin instance) { - Skin reference = GetSkin(item); - - if (!string.IsNullOrEmpty(reference.Configuration.SkinInfo.Name)) + if (!string.IsNullOrEmpty(instance.Configuration.SkinInfo.Name)) { - item.Name = reference.Configuration.SkinInfo.Name; - item.Creator = reference.Configuration.SkinInfo.Creator; + item.Name = instance.Configuration.SkinInfo.Name; + item.Creator = instance.Configuration.SkinInfo.Creator; } else { @@ -147,15 +152,48 @@ namespace osu.Game.Skinning /// /// The skin to lookup. /// A instance correlating to the provided . - public Skin GetSkin(SkinInfo skinInfo) + public Skin GetSkin(SkinInfo skinInfo) => skinInfo.CreateInstance(legacyDefaultResources, this); + + /// + /// Ensure that the current skin is in a state it can accept user modifications. + /// This will create a copy of any internal skin and being tracking in the database if not already. + /// + public void EnsureMutableSkin() { - if (skinInfo == SkinInfo.Default) - return new DefaultSkin(); + if (CurrentSkinInfo.Value.ID >= 1) return; - if (skinInfo == DefaultLegacySkin.Info) - return new DefaultLegacySkin(legacyDefaultResources, this); + var skin = CurrentSkin.Value; - return new LegacySkin(skinInfo, this); + // if the user is attempting to save one of the default skin implementations, create a copy first. + CurrentSkinInfo.Value = Import(new SkinInfo + { + Name = skin.SkinInfo.Name + " (modified)", + Creator = skin.SkinInfo.Creator, + InstantiationInfo = skin.SkinInfo.InstantiationInfo, + }).Result; + } + + public void Save(Skin skin) + { + if (skin.SkinInfo.ID <= 0) + throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); + + foreach (var drawableInfo in skin.DrawableComponentInfo) + { + string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) + { + string filename = $"{drawableInfo.Key}.json"; + + var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); + + if (oldFile != null) + ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename); + else + AddFile(skin.SkinInfo, streamContent, filename); + } + } } /// diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index 5a48bc4baf..fc2730ca44 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -23,7 +23,7 @@ namespace osu.Game.Skinning /// Whether the drawable component should be centered in available space. /// Defaults to true. /// - public bool CentreComponent { get; set; } = true; + public bool CentreComponent = true; public new Axes AutoSizeAxes { @@ -42,7 +42,8 @@ namespace osu.Game.Skinning /// A function to create the default skin implementation of this element. /// A conditional to decide whether to allow fallback to the default implementation if a skinned element is not present. /// How (if at all) the should be resize to fit within our own bounds. - public SkinnableDrawable(ISkinComponent component, Func defaultImplementation, Func allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) + public SkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, Func allowFallback = null, + ConfineMode confineMode = ConfineMode.NoScaling) : this(component, allowFallback, confineMode) { createDefault = defaultImplementation; @@ -68,7 +69,7 @@ namespace osu.Game.Skinning private bool isDefault; - protected virtual Drawable CreateDefault(ISkinComponent component) => createDefault(component); + protected virtual Drawable CreateDefault(ISkinComponent component) => createDefault?.Invoke(component) ?? Empty(); /// /// Whether to apply size restrictions (specified via ) to the default implementation. diff --git a/osu.Game/Skinning/IDefaultSkinnableTarget.cs b/osu.Game/Skinning/SkinnableTarget.cs similarity index 51% rename from osu.Game/Skinning/IDefaultSkinnableTarget.cs rename to osu.Game/Skinning/SkinnableTarget.cs index 24fb454af8..7b1eae126c 100644 --- a/osu.Game/Skinning/IDefaultSkinnableTarget.cs +++ b/osu.Game/Skinning/SkinnableTarget.cs @@ -3,10 +3,8 @@ namespace osu.Game.Skinning { - /// - /// The default placement location for new s. - /// - public interface IDefaultSkinnableTarget : ISkinnableTarget + public enum SkinnableTarget { + MainHUDComponents } } diff --git a/osu.Game/Skinning/SkinnableTargetComponent.cs b/osu.Game/Skinning/SkinnableTargetComponent.cs new file mode 100644 index 0000000000..a17aafe6e7 --- /dev/null +++ b/osu.Game/Skinning/SkinnableTargetComponent.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Skinning +{ + public class SkinnableTargetComponent : ISkinComponent + { + public readonly SkinnableTarget Target; + + public string LookupName => Target.ToString(); + + public SkinnableTargetComponent(SkinnableTarget target) + { + Target = target; + } + } +} diff --git a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs new file mode 100644 index 0000000000..2107ca7a8b --- /dev/null +++ b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs @@ -0,0 +1,46 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Skinning +{ + /// + /// A container which groups the components of a into a single object. + /// Optionally also applies a default layout to the components. + /// + [Serializable] + public class SkinnableTargetComponentsContainer : Container, ISkinnableDrawable + { + public bool IsEditable => false; + + private readonly Action applyDefaults; + + /// + /// Construct a wrapper with defaults that should be applied once. + /// + /// A function to apply the default layout. + public SkinnableTargetComponentsContainer(Action applyDefaults) + : this() + { + this.applyDefaults = applyDefaults; + } + + [JsonConstructor] + public SkinnableTargetComponentsContainer() + { + RelativeSizeAxes = Axes.Both; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // schedule is required to allow children to run their LoadComplete and take on their correct sizes. + ScheduleAfterChildren(() => applyDefaults?.Invoke(this)); + } + } +} diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs new file mode 100644 index 0000000000..a4d7f621eb --- /dev/null +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Skinning +{ + public class SkinnableTargetContainer : SkinReloadableDrawable, ISkinnableTarget + { + private SkinnableTargetComponentsContainer content; + + public SkinnableTarget Target { get; } + + public IBindableList Components => components; + + private readonly BindableList components = new BindableList(); + + public SkinnableTargetContainer(SkinnableTarget target) + { + Target = target; + } + + /// + /// Reload all components in this container from the current skin. + /// + public void Reload() + { + ClearInternal(); + components.Clear(); + + content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; + + if (content != null) + { + LoadComponentAsync(content, wrapper => + { + AddInternal(wrapper); + components.AddRange(wrapper.Children.OfType()); + }); + } + } + + /// + /// Add a new skinnable component to this target. + /// + /// The component to add. + /// Thrown when attempting to add an element to a target which is not supported by the current skin. + /// Thrown if the provided instance is not a . + public void Add(ISkinnableDrawable component) + { + if (content == null) + throw new NotSupportedException("Attempting to add a new component to a target container which is not supported by the current skin."); + + if (!(component is Drawable drawable)) + throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(drawable)); + + content.Add(drawable); + components.Add(component); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + Reload(); + } + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 7775c2bd24..db344b28dd 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -16,6 +16,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { public abstract class MultiplayerTestScene : RoomTestScene { + public const int PLAYER_1_ID = 55; + public const int PLAYER_2_ID = 56; + [Cached(typeof(StatefulMultiplayerClient))] public TestMultiplayerClient Client { get; } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index de77a15da0..167cf705a7 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -25,6 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); + public Room? APIRoom { get; private set; } + public Action? RoomSetupAction; [Resolved] @@ -138,10 +140,16 @@ namespace osu.Game.Tests.Visual.Multiplayer RoomSetupAction?.Invoke(room); RoomSetupAction = null; + APIRoom = apiRoom; + return Task.FromResult(room); } - protected override Task LeaveRoomInternal() => Task.CompletedTask; + protected override Task LeaveRoomInternal() + { + APIRoom = null; + return Task.CompletedTask; + } public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs new file mode 100644 index 0000000000..cc8437479d --- /dev/null +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs @@ -0,0 +1,90 @@ +// 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.Concurrent; +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Utils; +using osu.Game.Online; +using osu.Game.Online.Spectator; +using osu.Game.Replays.Legacy; +using osu.Game.Scoring; + +namespace osu.Game.Tests.Visual.Spectator +{ + public class TestSpectatorStreamingClient : SpectatorStreamingClient + { + public new BindableList PlayingUsers => (BindableList)base.PlayingUsers; + private readonly ConcurrentDictionary watchingUsers = new ConcurrentDictionary(); + + private readonly Dictionary userBeatmapDictionary = new Dictionary(); + private readonly Dictionary userSentStateDictionary = new Dictionary(); + + public TestSpectatorStreamingClient() + : base(new DevelopmentEndpointConfiguration()) + { + } + + public void StartPlay(int userId, int beatmapId) + { + userBeatmapDictionary[userId] = beatmapId; + sendState(userId, beatmapId); + } + + public void EndPlay(int userId, int beatmapId) + { + ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState + { + BeatmapID = beatmapId, + RulesetID = 0, + }); + + userBeatmapDictionary.Remove(userId); + userSentStateDictionary.Remove(userId); + } + + public void SendFrames(int userId, int index, int count) + { + var frames = new List(); + + for (int i = index; i < index + count; i++) + { + var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1; + + frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); + } + + var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames); + ((ISpectatorClient)this).UserSentFrames(userId, bundle); + + if (!userSentStateDictionary[userId]) + sendState(userId, userBeatmapDictionary[userId]); + } + + public override void WatchUser(int userId) + { + base.WatchUser(userId); + + // When newly watching a user, the server sends the playing state immediately. + if (watchingUsers.TryAdd(userId, 0) && PlayingUsers.Contains(userId)) + sendState(userId, userBeatmapDictionary[userId]); + } + + public override void StopWatchingUser(int userId) + { + base.StopWatchingUser(userId); + watchingUsers.TryRemove(userId, out _); + } + + private void sendState(int userId, int beatmapId) + { + ((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState + { + BeatmapID = beatmapId, + RulesetID = 0, + }); + + userSentStateDictionary[userId] = true; + } + } +}