diff --git a/osu.Android.props b/osu.Android.props
index 90d131b117..57550cfb93 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs
index bde323f187..ca323b5911 100644
--- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
RelativeSizeAxes = Axes.Both,
Width = 0.5f,
- Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 0), _ => new DefaultColumnBackground())
+ Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
{
RelativeSizeAxes = Axes.Both
}
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
RelativeSizeAxes = Axes.Both,
Width = 0.5f,
- Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 1), _ => new DefaultColumnBackground())
+ Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
{
RelativeSizeAxes = Axes.Both
}
diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs
index 7e80419944..c58c07c83b 100644
--- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs
+++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
RelativeSizeAxes = Axes.Both,
Width = 0.5f,
- Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 0), _ => new DefaultKeyArea())
+ Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea())
{
RelativeSizeAxes = Axes.Both
},
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
RelativeSizeAxes = Axes.Both,
Width = 0.5f,
- Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 1), _ => new DefaultKeyArea())
+ Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea())
{
RelativeSizeAxes = Axes.Both
},
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
index 387c5f4195..471dad87d5 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
@@ -408,6 +409,9 @@ namespace osu.Game.Rulesets.Mania.Tests
judgementResults = new List();
});
+ AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
+ AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
+
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true);
}
diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
index a0a43ed6ca..dc858fb54f 100644
--- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
+++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs
@@ -58,8 +58,9 @@ namespace osu.Game.Rulesets.Mania.Edit
EditorBeatmap.PerformOnSelection(h =>
{
- if (h is ManiaHitObject maniaObj)
- maniaObj.Column += columnDelta;
+ maniaPlayfield.Remove(h);
+ ((ManiaHitObject)h).Column += columnDelta;
+ maniaPlayfield.Add(h);
});
}
}
diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs
index f078345fc1..9aebf51576 100644
--- a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs
+++ b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs
@@ -9,12 +9,6 @@ namespace osu.Game.Rulesets.Mania
{
public class ManiaSkinComponent : GameplaySkinComponent
{
- ///
- /// The intended index for this component.
- /// May be null if the component does not exist in a .
- ///
- public readonly int? TargetColumn;
-
///
/// The intended for this component.
/// May be null if the component is not a direct member of a .
@@ -25,12 +19,10 @@ namespace osu.Game.Rulesets.Mania
/// Creates a new .
///
/// The component.
- /// The intended index for this component. May be null if the component does not exist in a .
/// The intended for this component. May be null if the component is not a direct member of a .
- public ManiaSkinComponent(ManiaSkinComponents component, int? targetColumn = null, StageDefinition? stageDefinition = null)
+ public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null)
: base(component)
{
- TargetColumn = targetColumn;
StageDefinition = stageDefinition;
}
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
index 02829d87bd..d1310d42eb 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -12,6 +13,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
@@ -29,21 +31,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child;
- private readonly Container headContainer;
- private readonly Container tailContainer;
- private readonly Container tickContainer;
+ private Container headContainer;
+ private Container tailContainer;
+ private Container tickContainer;
///
/// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed.
///
- private readonly Container sizingContainer;
+ private Container sizingContainer;
///
/// Contains the contents of the hold note that should be masked as the hold note is being pressed. Follows changes in the size of .
///
- private readonly Container maskingContainer;
+ private Container maskingContainer;
- private readonly SkinnableDrawable bodyPiece;
+ private SkinnableDrawable bodyPiece;
///
/// Time at which the user started holding this hold note. Null if the user is not holding this hold note.
@@ -60,11 +62,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
///
private double? releaseTime;
+ public DrawableHoldNote()
+ : this(null)
+ {
+ }
+
public DrawableHoldNote(HoldNote hitObject)
: base(hitObject)
{
- RelativeSizeAxes = Axes.X;
+ }
+ [BackgroundDependencyLoader]
+ private void load()
+ {
Container maskedContents;
AddRangeInternal(new Drawable[]
@@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
headContainer = new Container { RelativeSizeAxes = Axes.Both }
}
},
- bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody, hitObject.Column), _ => new DefaultBodyPiece
+ bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
{
RelativeSizeAxes = Axes.Both,
})
@@ -105,6 +115,16 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
});
}
+ protected override void OnApply()
+ {
+ base.OnApply();
+
+ sizingContainer.Size = Vector2.One;
+ HoldStartTime = null;
+ HoldBrokenTime = null;
+ releaseTime = null;
+ }
+
protected override void AddNestedHitObject(DrawableHitObject hitObject)
{
base.AddNestedHitObject(hitObject);
@@ -128,37 +148,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected override void ClearNestedHitObjects()
{
base.ClearNestedHitObjects();
- headContainer.Clear();
- tailContainer.Clear();
- tickContainer.Clear();
+ headContainer.Clear(false);
+ tailContainer.Clear(false);
+ tickContainer.Clear(false);
}
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
{
switch (hitObject)
{
- case TailNote _:
- return new DrawableHoldNoteTail(this)
- {
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopCentre,
- AccentColour = { BindTarget = AccentColour }
- };
+ case TailNote tail:
+ return new DrawableHoldNoteTail(tail);
- case Note _:
- return new DrawableHoldNoteHead(this)
- {
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopCentre,
- AccentColour = { BindTarget = AccentColour }
- };
+ case HeadNote head:
+ return new DrawableHoldNoteHead(head);
case HoldNoteTick tick:
- return new DrawableHoldNoteTick(tick)
- {
- HoldStartTime = () => HoldStartTime,
- AccentColour = { BindTarget = AccentColour }
- };
+ return new DrawableHoldNoteTick(tick);
}
return base.CreateNestedHitObject(hitObject);
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs
index 35ba2465fa..be600f0d47 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.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 osu.Framework.Graphics;
using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
@@ -12,11 +13,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead;
- public DrawableHoldNoteHead(DrawableHoldNote holdNote)
- : base(holdNote.HitObject.Head)
+ public DrawableHoldNoteHead()
+ : this(null)
{
}
+ public DrawableHoldNoteHead(HeadNote headNote)
+ : base(headNote)
+ {
+ Anchor = Anchor.TopCentre;
+ Origin = Anchor.TopCentre;
+ }
+
public void UpdateResult() => base.UpdateResult(true);
protected override void UpdateInitialTransforms()
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
index 3a00933e4d..18aa3f66d4 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Diagnostics;
+using osu.Framework.Graphics;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
@@ -20,12 +21,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail;
- private readonly DrawableHoldNote holdNote;
+ protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
- public DrawableHoldNoteTail(DrawableHoldNote holdNote)
- : base(holdNote.HitObject.Tail)
+ public DrawableHoldNoteTail()
+ : this(null)
{
- this.holdNote = holdNote;
+ }
+
+ public DrawableHoldNoteTail(TailNote tailNote)
+ : base(tailNote)
+ {
+ Anchor = Anchor.TopCentre;
+ Origin = Anchor.TopCentre;
}
public void UpdateResult() => base.UpdateResult(true);
@@ -54,7 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
ApplyResult(r =>
{
// If the head wasn't hit or the hold note was broken, cap the max score to Meh.
- if (result > HitResult.Meh && (!holdNote.Head.IsHit || holdNote.HoldBrokenTime != null))
+ if (result > HitResult.Meh && (!HoldNote.Head.IsHit || HoldNote.HoldBrokenTime != null))
result = HitResult.Meh;
r.Type = result;
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs
index 98931dceed..f040dad135 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs
@@ -2,7 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using osuTK;
+using System.Diagnostics;
+using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -19,38 +20,48 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
///
/// References the time at which the user started holding the hold note.
///
- public Func HoldStartTime;
+ private Func holdStartTime;
+
+ private Container glowContainer;
+
+ public DrawableHoldNoteTick()
+ : this(null)
+ {
+ }
public DrawableHoldNoteTick(HoldNoteTick hitObject)
: base(hitObject)
{
- Container glowContainer;
-
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
RelativeSizeAxes = Axes.X;
- Size = new Vector2(1);
+ }
- AddRangeInternal(new[]
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ AddInternal(glowContainer = new CircularContainer
{
- glowContainer = new CircularContainer
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ RelativeSizeAxes = Axes.Both,
+ Masking = true,
+ Children = new[]
{
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopCentre,
- RelativeSizeAxes = Axes.Both,
- Masking = true,
- Children = new[]
+ new Box
{
- new Box
- {
- RelativeSizeAxes = Axes.Both,
- Alpha = 0,
- AlwaysPresent = true
- }
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0,
+ AlwaysPresent = true
}
}
});
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
AccentColour.BindValueChanged(colour =>
{
@@ -64,12 +75,29 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
}, true);
}
+ protected override void OnApply()
+ {
+ base.OnApply();
+
+ Debug.Assert(ParentHitObject != null);
+
+ var holdNote = (DrawableHoldNote)ParentHitObject;
+ holdStartTime = () => holdNote.HoldStartTime;
+ }
+
+ protected override void OnFree()
+ {
+ base.OnFree();
+
+ holdStartTime = null;
+ }
+
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (Time.Current < HitObject.StartTime)
return;
- var startTime = HoldStartTime?.Invoke();
+ var startTime = holdStartTime?.Invoke();
if (startTime == null || startTime > HitObject.StartTime)
ApplyResult(r => r.Type = r.Judgement.MinResult);
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
index 003646d654..380ab35339 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
@@ -50,6 +50,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected DrawableManiaHitObject(ManiaHitObject hitObject)
: base(hitObject)
{
+ RelativeSizeAxes = Axes.X;
}
[BackgroundDependencyLoader(true)]
@@ -59,9 +60,31 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Action.BindTo(action);
Direction.BindTo(scrollingInfo.Direction);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
Direction.BindValueChanged(OnDirectionChanged, true);
}
+ protected override void OnApply()
+ {
+ base.OnApply();
+
+ if (ParentHitObject != null)
+ AccentColour.BindTo(ParentHitObject.AccentColour);
+ }
+
+ protected override void OnFree()
+ {
+ base.OnFree();
+
+ if (ParentHitObject != null)
+ AccentColour.UnbindFrom(ParentHitObject.AccentColour);
+ }
+
private double computedLifetimeStart;
public override double LifetimeStart
@@ -147,12 +170,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public abstract class DrawableManiaHitObject : DrawableManiaHitObject
where TObject : ManiaHitObject
{
- public new readonly TObject HitObject;
+ public new TObject HitObject => (TObject)base.HitObject;
protected DrawableManiaHitObject(TObject hitObject)
: base(hitObject)
{
- HitObject = hitObject;
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
index 36565e14aa..33d872dfb6 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs
@@ -33,31 +33,37 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note;
- private readonly Drawable headPiece;
+ private Drawable headPiece;
+
+ public DrawableNote()
+ : this(null)
+ {
+ }
public DrawableNote(Note hitObject)
: base(hitObject)
{
- RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
-
- AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component, hitObject.Column), _ => new DefaultNotePiece())
- {
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y
- });
}
[BackgroundDependencyLoader(true)]
private void load(ManiaRulesetConfigManager rulesetConfig)
{
rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring);
+
+ AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component), _ => new DefaultNotePiece())
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y
+ });
}
protected override void LoadComplete()
{
- HitObject.StartTimeBindable.BindValueChanged(_ => updateSnapColour());
- configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour(), true);
+ base.LoadComplete();
+
+ configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour());
+ StartTimeBindable.BindValueChanged(_ => updateSnapColour(), true);
}
protected override void OnDirectionChanged(ValueChangedEvent e)
@@ -102,7 +108,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
private void updateSnapColour()
{
- if (beatmap == null) return;
+ if (beatmap == null || HitObject == null) return;
int snapDivisor = beatmap.ControlPointInfo.GetClosestBeatDivisor(HitObject.StartTime);
diff --git a/osu.Game.Rulesets.Mania/Objects/HeadNote.cs b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs
new file mode 100644
index 0000000000..e69cc62aed
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs
@@ -0,0 +1,9 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Mania.Objects
+{
+ public class HeadNote : Note
+ {
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs
index 6cc7ff92d3..43e876b7aa 100644
--- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs
@@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Mania.Objects
///
/// The head note of the hold.
///
- public Note Head { get; private set; }
+ public HeadNote Head { get; private set; }
///
/// The tail note of the hold.
@@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.Objects
createTicks(cancellationToken);
- AddNested(Head = new Note
+ AddNested(Head = new HeadNote
{
StartTime = StartTime,
Column = Column,
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index 0f02e2cd4b..9b5893b268 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -17,6 +17,7 @@ using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning;
using osuTK;
using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
namespace osu.Game.Rulesets.Mania.UI
@@ -55,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.UI
RelativeSizeAxes = Axes.Y;
Width = COLUMN_WIDTH;
- Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, Index), _ => new DefaultColumnBackground())
+ Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground())
{
RelativeSizeAxes = Axes.Both
};
@@ -66,7 +67,7 @@ namespace osu.Game.Rulesets.Mania.UI
// For input purposes, the background is added at the highest depth, but is then proxied back below all other elements
background.CreateProxy(),
HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both },
- new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, Index), _ => new DefaultKeyArea())
+ new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea())
{
RelativeSizeAxes = Axes.Both
},
@@ -83,6 +84,19 @@ namespace osu.Game.Rulesets.Mania.UI
hitPolicy = new OrderedHitPolicy(HitObjectContainer);
TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy());
+
+ RegisterPool(10, 50);
+ RegisterPool(10, 50);
+ RegisterPool(10, 50);
+ RegisterPool(10, 50);
+ RegisterPool(50, 250);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ NewResult += OnNewResult;
}
public ColumnType ColumnType { get; set; }
@@ -98,28 +112,14 @@ namespace osu.Game.Rulesets.Mania.UI
return dependencies;
}
- ///
- /// Adds a DrawableHitObject to this Playfield.
- ///
- /// The DrawableHitObject to add.
- public override void Add(DrawableHitObject hitObject)
+ protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject)
{
- hitObject.AccentColour.Value = AccentColour;
- hitObject.OnNewResult += OnNewResult;
+ base.OnNewDrawableHitObject(drawableHitObject);
- DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject;
+ DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject;
+
+ maniaObject.AccentColour.Value = AccentColour;
maniaObject.CheckHittable = hitPolicy.IsHittable;
-
- base.Add(hitObject);
- }
-
- public override bool Remove(DrawableHitObject h)
- {
- if (!base.Remove(h))
- return false;
-
- h.OnNewResult -= OnNewResult;
- return true;
}
internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result)
diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs
index b365ae45a9..f69d2aafdc 100644
--- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs
+++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.UI.Components
RelativeSizeAxes = Axes.Both,
Depth = 2,
},
- hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget())
+ hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget())
{
RelativeSizeAxes = Axes.X,
Depth = 1
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index 4ee060e91e..e497646a13 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -18,7 +18,6 @@ using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Objects;
-using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
@@ -134,20 +133,7 @@ namespace osu.Game.Rulesets.Mania.UI
protected override PassThroughInputManager CreateInputManager() => new ManiaInputManager(Ruleset.RulesetInfo, Variant);
- public override DrawableHitObject CreateDrawableRepresentation(ManiaHitObject h)
- {
- switch (h)
- {
- case HoldNote holdNote:
- return new DrawableHoldNote(holdNote);
-
- case Note note:
- return new DrawableNote(note);
-
- default:
- return null;
- }
- }
+ public override DrawableHitObject CreateDrawableRepresentation(ManiaHitObject h) => null;
protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay);
diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
index 271e432e8d..8830c440c0 100644
--- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
+++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs
@@ -9,6 +9,7 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
@@ -56,6 +57,10 @@ namespace osu.Game.Rulesets.Mania.UI
}
}
+ public override void Add(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Add(hitObject);
+
+ public override bool Remove(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Remove(hitObject);
+
public override void Add(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Add(h);
public override bool Remove(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Remove(h);
diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs
index 64b7d7d550..90d3c6c4c7 100644
--- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs
+++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.UI
[BackgroundDependencyLoader]
private void load()
{
- InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, column.Index), _ => new DefaultHitExplosion())
+ InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion), _ => new DefaultHitExplosion())
{
RelativeSizeAxes = Axes.Both
};
diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs
index dc34bffab1..8c703e7a8a 100644
--- a/osu.Game.Rulesets.Mania/UI/Stage.cs
+++ b/osu.Game.Rulesets.Mania/UI/Stage.cs
@@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.UI.Components;
+using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
@@ -132,33 +133,19 @@ namespace osu.Game.Rulesets.Mania.UI
}
}
- public override void Add(DrawableHitObject h)
+ protected override void LoadComplete()
{
- var maniaObject = (ManiaHitObject)h.HitObject;
-
- int columnIndex = -1;
-
- maniaObject.ColumnBindable.BindValueChanged(_ =>
- {
- if (columnIndex != -1)
- Columns.ElementAt(columnIndex).Remove(h);
-
- columnIndex = maniaObject.Column - firstColumnIndex;
- Columns.ElementAt(columnIndex).Add(h);
- }, true);
-
- h.OnNewResult += OnNewResult;
+ base.LoadComplete();
+ NewResult += OnNewResult;
}
- public override bool Remove(DrawableHitObject h)
- {
- var maniaObject = (ManiaHitObject)h.HitObject;
- int columnIndex = maniaObject.Column - firstColumnIndex;
- Columns.ElementAt(columnIndex).Remove(h);
+ public override void Add(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Add(hitObject);
- h.OnNewResult -= OnNewResult;
- return true;
- }
+ public override bool Remove(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Remove(hitObject);
+
+ public override void Add(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Add(h);
+
+ public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h);
public void Add(BarLine barline) => base.Add(new DrawableBarLine(barline));
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
index 44c3056910..c36768baba 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
@@ -243,7 +243,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
int totalCount = Pieces.Count(p => p.IsSelected.Value);
int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type);
- var item = new PathTypeMenuItem(type, () =>
+ var item = new TernaryStateRadioMenuItem(type == null ? "Inherit" : type.ToString().Humanize(), MenuItemType.Standard, _ =>
{
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
updatePathType(p, type);
@@ -258,15 +258,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
return item;
}
-
- private class PathTypeMenuItem : TernaryStateMenuItem
- {
- public PathTypeMenuItem(PathType? type, Action action)
- : base(type == null ? "Inherit" : type.ToString().Humanize(), changeState, MenuItemType.Standard, _ => action?.Invoke())
- {
- }
-
- private static TernaryState changeState(TernaryState state) => TernaryState.True;
- }
}
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs
index 17e7fb81f6..0d0fd136a7 100644
--- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs
@@ -14,10 +14,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{
private readonly HitPiece piece;
- private static Hit hit;
+ public new Hit HitObject => (Hit)base.HitObject;
public HitPlacementBlueprint()
- : base(hit = new Hit())
+ : base(new Hit())
{
InternalChild = piece = new HitPiece
{
@@ -30,12 +30,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
switch (e.Button)
{
case MouseButton.Left:
- hit.Type = HitType.Centre;
+ HitObject.Type = HitType.Centre;
EndPlacement(true);
return true;
case MouseButton.Right:
- hit.Type = HitType.Rim;
+ HitObject.Type = HitType.Rim;
EndPlacement(true);
return true;
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
index 48ee0d4cf4..a24130d6ac 100644
--- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs
@@ -76,10 +76,10 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override IEnumerable
public Action PostNotification { protected get; set; }
- ///
- /// Set a storage with access to an osu-stable install for import purposes.
- ///
- public Func GetStableStorage { private get; set; }
-
///
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
///
- public Task ImportFromStableAsync()
+ public Task ImportFromStableAsync(StableStorage stableStorage)
{
- var stable = GetStableStorage?.Invoke();
-
- if (stable == null)
- {
- Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
- return Task.CompletedTask;
- }
-
- if (!stable.Exists(database_name))
+ if (!stableStorage.Exists(database_name))
{
// This handles situations like when the user does not have a collections.db file
Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
@@ -123,7 +108,7 @@ namespace osu.Game.Collections
return Task.Run(async () =>
{
- using (var stream = stable.GetStream(database_name))
+ using (var stream = stableStorage.GetStream(database_name))
await Import(stream).ConfigureAwait(false);
});
}
diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs
index 09412b1f1b..43bbd725c3 100644
--- a/osu.Game/Configuration/OsuConfigManager.cs
+++ b/osu.Game/Configuration/OsuConfigManager.cs
@@ -104,7 +104,6 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.KeyOverlay, false);
SetDefault(OsuSetting.PositionalHitSounds, true);
SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true);
- SetDefault(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth);
SetDefault(OsuSetting.FloatingComments, false);
@@ -213,7 +212,6 @@ namespace osu.Game.Configuration
KeyOverlay,
PositionalHitSounds,
AlwaysPlayFirstComboBreak,
- ScoreMeter,
FloatingComments,
HUDVisibilityMode,
ShowProgressGraph,
diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs
deleted file mode 100644
index ddbd2327c2..0000000000
--- a/osu.Game/Configuration/ScoreMeterType.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System.ComponentModel;
-
-namespace osu.Game.Configuration
-{
- public enum ScoreMeterType
- {
- [Description("None")]
- None,
-
- [Description("Hit Error (left)")]
- HitErrorLeft,
-
- [Description("Hit Error (right)")]
- HitErrorRight,
-
- [Description("Hit Error (left+right)")]
- HitErrorBoth,
-
- [Description("Hit Error (bottom)")]
- HitErrorBottom,
-
- [Description("Colour (left)")]
- ColourLeft,
-
- [Description("Colour (right)")]
- ColourRight,
-
- [Description("Colour (left+right)")]
- ColourBoth,
-
- [Description("Colour (bottom)")]
- ColourBottom,
- }
-}
diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index e0f80d2743..8efd451857 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -10,7 +10,6 @@ using System.Threading.Tasks;
using Humanizer;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
-using osu.Framework;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
@@ -81,8 +80,6 @@ namespace osu.Game.Database
public virtual IEnumerable HandledExtensions => new[] { ".zip" };
- public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
-
protected readonly FileStore Files;
protected readonly IDatabaseContextFactory ContextFactory;
@@ -669,16 +666,6 @@ namespace osu.Game.Database
#region osu-stable import
- ///
- /// Set a storage with access to an osu-stable install for import purposes.
- ///
- public Func GetStableStorage { private get; set; }
-
- ///
- /// Denotes whether an osu-stable installation is present to perform automated imports from.
- ///
- public bool StableInstallationAvailable => GetStableStorage?.Invoke() != null;
-
///
/// The relative path from osu-stable's data directory to import items from.
///
@@ -700,22 +687,16 @@ namespace osu.Game.Database
///
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
///
- public Task ImportFromStableAsync()
+ public Task ImportFromStableAsync(StableStorage stableStorage)
{
- var stableStorage = GetStableStorage?.Invoke();
-
- if (stableStorage == null)
- {
- Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
- return Task.CompletedTask;
- }
-
var storage = PrepareStableStorage(stableStorage);
+ // Handle situations like when the user does not have a Skins folder.
if (!storage.ExistsDirectory(ImportFromStablePath))
{
- // This handles situations like when the user does not have a Skins folder
- Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
+ string fullPath = storage.GetFullPath(ImportFromStablePath);
+
+ Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
diff --git a/osu.Game/Database/StableImportManager.cs b/osu.Game/Database/StableImportManager.cs
new file mode 100644
index 0000000000..63a6db35c0
--- /dev/null
+++ b/osu.Game/Database/StableImportManager.cs
@@ -0,0 +1,96 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using osu.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions.EnumExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Platform;
+using osu.Game.Beatmaps;
+using osu.Game.Collections;
+using osu.Game.IO;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Settings.Sections.Maintenance;
+using osu.Game.Scoring;
+using osu.Game.Skinning;
+
+namespace osu.Game.Database
+{
+ public class StableImportManager : Component
+ {
+ [Resolved]
+ private SkinManager skins { get; set; }
+
+ [Resolved]
+ private BeatmapManager beatmaps { get; set; }
+
+ [Resolved]
+ private ScoreManager scores { get; set; }
+
+ [Resolved]
+ private CollectionManager collections { get; set; }
+
+ [Resolved]
+ private OsuGame game { get; set; }
+
+ [Resolved]
+ private DialogOverlay dialogOverlay { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private DesktopGameHost desktopGameHost { get; set; }
+
+ private StableStorage cachedStorage;
+
+ public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
+
+ public async Task ImportFromStableAsync(StableContent content)
+ {
+ var stableStorage = await getStableStorage().ConfigureAwait(false);
+ var importTasks = new List();
+
+ Task beatmapImportTask = Task.CompletedTask;
+ if (content.HasFlagFast(StableContent.Beatmaps))
+ importTasks.Add(beatmapImportTask = beatmaps.ImportFromStableAsync(stableStorage));
+
+ if (content.HasFlagFast(StableContent.Skins))
+ importTasks.Add(skins.ImportFromStableAsync(stableStorage));
+
+ if (content.HasFlagFast(StableContent.Collections))
+ importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
+
+ if (content.HasFlagFast(StableContent.Scores))
+ importTasks.Add(beatmapImportTask.ContinueWith(_ => scores.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
+
+ await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false);
+ }
+
+ private async Task getStableStorage()
+ {
+ if (cachedStorage != null)
+ return cachedStorage;
+
+ var stableStorage = game.GetStorageForStableInstall();
+ if (stableStorage != null)
+ return cachedStorage = stableStorage;
+
+ var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Schedule(() => dialogOverlay.Push(new StableDirectoryLocationDialog(taskCompletionSource)));
+ var stablePath = await taskCompletionSource.Task.ConfigureAwait(false);
+
+ return cachedStorage = new StableStorage(stablePath, desktopGameHost);
+ }
+ }
+
+ [Flags]
+ public enum StableContent
+ {
+ Beatmaps = 1 << 0,
+ Scores = 1 << 1,
+ Skins = 1 << 2,
+ Collections = 1 << 3,
+ All = Beatmaps | Scores | Skins | Collections
+ }
+}
diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs
index acf4065f49..5c623150b7 100644
--- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs
+++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs
@@ -9,28 +9,17 @@ namespace osu.Game.Graphics.UserInterface
///
/// An with three possible states.
///
- public class TernaryStateMenuItem : StatefulMenuItem
+ public abstract class TernaryStateMenuItem : StatefulMenuItem
{
///
/// Creates a new .
///
/// The text to display.
+ /// A function to inform what the next state should be when this item is clicked.
/// The type of action which this performs.
/// A delegate to be invoked when this is pressed.
- public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null)
- : this(text, getNextState, type, action)
- {
- }
-
- ///
- /// Creates a new .
- ///
- /// The text to display.
- /// A function that mutates a state to another state after this is pressed.
- /// The type of action which this performs.
- /// A delegate to be invoked when this is pressed.
- protected TernaryStateMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action)
- : base(text, changeStateFunc, type, action)
+ protected TernaryStateMenuItem(string text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null)
+ : base(text, nextStateFunction, type, action)
{
}
@@ -47,23 +36,5 @@ namespace osu.Game.Graphics.UserInterface
return null;
}
-
- private static TernaryState getNextState(TernaryState state)
- {
- switch (state)
- {
- case TernaryState.False:
- return TernaryState.True;
-
- case TernaryState.Indeterminate:
- return TernaryState.True;
-
- case TernaryState.True:
- return TernaryState.False;
-
- default:
- throw new ArgumentOutOfRangeException(nameof(state), state, null);
- }
- }
}
}
diff --git a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs
new file mode 100644
index 0000000000..46eda06294
--- /dev/null
+++ b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs
@@ -0,0 +1,26 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+
+namespace osu.Game.Graphics.UserInterface
+{
+ ///
+ /// A ternary state menu item which will always set the item to true on click, even if already true.
+ ///
+ public class TernaryStateRadioMenuItem : TernaryStateMenuItem
+ {
+ ///
+ /// Creates a new .
+ ///
+ /// The text to display.
+ /// The type of action which this performs.
+ /// A delegate to be invoked when this is pressed.
+ public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null)
+ : base(text, getNextState, type, action)
+ {
+ }
+
+ private static TernaryState getNextState(TernaryState state) => TernaryState.True;
+ }
+}
diff --git a/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs
new file mode 100644
index 0000000000..ce951984fd
--- /dev/null
+++ b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs
@@ -0,0 +1,42 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+
+namespace osu.Game.Graphics.UserInterface
+{
+ ///
+ /// A ternary state menu item which toggles the state of this item false if clicked when true.
+ ///
+ public class TernaryStateToggleMenuItem : TernaryStateMenuItem
+ {
+ ///
+ /// Creates a new .
+ ///
+ /// The text to display.
+ /// The type of action which this performs.
+ /// A delegate to be invoked when this is pressed.
+ public TernaryStateToggleMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null)
+ : base(text, getNextState, type, action)
+ {
+ }
+
+ private static TernaryState getNextState(TernaryState state)
+ {
+ switch (state)
+ {
+ case TernaryState.False:
+ return TernaryState.True;
+
+ case TernaryState.Indeterminate:
+ return TernaryState.True;
+
+ case TernaryState.True:
+ return TernaryState.False;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(state), state, null);
+ }
+ }
+ }
+}
diff --git a/osu.Game/Online/API/Requests/GetNewsRequest.cs b/osu.Game/Online/API/Requests/GetNewsRequest.cs
index 36d9dc0652..992ccc6d59 100644
--- a/osu.Game/Online/API/Requests/GetNewsRequest.cs
+++ b/osu.Game/Online/API/Requests/GetNewsRequest.cs
@@ -8,10 +8,12 @@ namespace osu.Game.Online.API.Requests
{
public class GetNewsRequest : APIRequest
{
+ private readonly int? year;
private readonly Cursor cursor;
- public GetNewsRequest(Cursor cursor = null)
+ public GetNewsRequest(int? year = null, Cursor cursor = null)
{
+ this.year = year;
this.cursor = cursor;
}
@@ -19,6 +21,10 @@ namespace osu.Game.Online.API.Requests
{
var req = base.CreateWebRequest();
req.AddCursor(cursor);
+
+ if (year.HasValue)
+ req.AddParameter("year", year.Value.ToString());
+
return req;
}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index 4529dfd0a7..2e65f7cf1c 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -3,132 +3,621 @@
#nullable enable
+using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Logging;
using osu.Game.Beatmaps;
+using osu.Game.Database;
using osu.Game.Online.API;
-using osu.Game.Online.API.Requests;
using osu.Game.Online.Rooms;
+using osu.Game.Online.Rooms.RoomStatuses;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Users;
+using osu.Game.Utils;
namespace osu.Game.Online.Multiplayer
{
- public class MultiplayerClient : StatefulMultiplayerClient
+ public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
{
- private readonly string endpoint;
+ ///
+ /// Invoked when any change occurs to the multiplayer room.
+ ///
+ public event Action? RoomUpdated;
- private IHubClientConnector? connector;
+ ///
+ /// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
+ ///
+ public event Action? LoadRequested;
- public override IBindable IsConnected { get; } = new BindableBool();
+ ///
+ /// Invoked when the multiplayer server requests gameplay to be started.
+ ///
+ public event Action? MatchStarted;
- private HubConnection? connection => connector?.CurrentConnection;
+ ///
+ /// Invoked when the multiplayer server has finished collating results.
+ ///
+ public event Action? ResultsReady;
- public MultiplayerClient(EndpointConfiguration endpoints)
+ ///
+ /// Whether the is currently connected.
+ /// This is NOT thread safe and usage should be scheduled.
+ ///
+ public abstract IBindable IsConnected { get; }
+
+ ///
+ /// The joined .
+ ///
+ public MultiplayerRoom? Room { get; private set; }
+
+ ///
+ /// The users in the joined which are participating in the current gameplay loop.
+ ///
+ public readonly BindableList CurrentMatchPlayingUserIds = new BindableList();
+
+ public readonly Bindable CurrentMatchPlayingItem = new Bindable();
+
+ ///
+ /// The corresponding to the local player, if available.
+ ///
+ public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
+
+ ///
+ /// Whether the is the host in .
+ ///
+ public bool IsHost
{
- endpoint = endpoints.MultiplayerEndpointUrl;
- }
-
- [BackgroundDependencyLoader]
- private void load(IAPIProvider api)
- {
- connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint);
-
- if (connector != null)
+ get
{
- connector.ConfigureConnection = connection =>
- {
- // this is kind of SILLY
- // https://github.com/dotnet/aspnetcore/issues/15198
- connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
- connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
- connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
- connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
- connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
- connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
- connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
- connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
- connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
- connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
- connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
- };
-
- IsConnected.BindTo(connector.IsConnected);
+ var localUser = LocalUser;
+ return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
}
}
- protected override Task JoinRoom(long roomId)
- {
- if (!IsConnected.Value)
- return Task.FromCanceled(new CancellationToken(true));
+ [Resolved]
+ protected IAPIProvider API { get; private set; } = null!;
- return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId);
+ [Resolved]
+ protected RulesetStore Rulesets { get; private set; } = null!;
+
+ [Resolved]
+ private UserLookupCache userLookupCache { get; set; } = null!;
+
+ private Room? apiRoom;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ IsConnected.BindValueChanged(connected =>
+ {
+ // clean up local room state on server disconnect.
+ if (!connected.NewValue && Room != null)
+ {
+ Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
+ LeaveRoom();
+ }
+ });
}
- protected override Task LeaveRoomInternal()
- {
- if (!IsConnected.Value)
- return Task.FromCanceled(new CancellationToken(true));
+ private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
+ private CancellationTokenSource? joinCancellationSource;
- return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
+ ///
+ /// Joins the for a given API .
+ ///
+ /// The API .
+ public async Task JoinRoom(Room room)
+ {
+ var cancellationSource = joinCancellationSource = new CancellationTokenSource();
+
+ await joinOrLeaveTaskChain.Add(async () =>
+ {
+ if (Room != null)
+ throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
+
+ Debug.Assert(room.RoomID.Value != null);
+
+ // Join the server-side room.
+ var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
+ Debug.Assert(joinedRoom != null);
+
+ // Populate users.
+ Debug.Assert(joinedRoom.Users != null);
+ await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
+
+ // Update the stored room (must be done on update thread for thread-safety).
+ await scheduleAsync(() =>
+ {
+ Room = joinedRoom;
+ apiRoom = room;
+ foreach (var user in joinedRoom.Users)
+ updateUserPlayingState(user.UserID, user.State);
+ }, cancellationSource.Token).ConfigureAwait(false);
+
+ // Update room settings.
+ await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
+ }, cancellationSource.Token).ConfigureAwait(false);
}
- public override Task TransferHost(int userId)
+ ///
+ /// Joins the with a given ID.
+ ///
+ /// The room ID.
+ /// The joined .
+ protected abstract Task JoinRoom(long roomId);
+
+ public Task LeaveRoom()
{
- if (!IsConnected.Value)
+ // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
+ // This includes the setting of Room itself along with the initial update of the room settings on join.
+ joinCancellationSource?.Cancel();
+
+ // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
+ // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
+ // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
+ var scheduledReset = scheduleAsync(() =>
+ {
+ apiRoom = null;
+ Room = null;
+ CurrentMatchPlayingUserIds.Clear();
+
+ RoomUpdated?.Invoke();
+ });
+
+ return joinOrLeaveTaskChain.Add(async () =>
+ {
+ await scheduledReset.ConfigureAwait(false);
+ await LeaveRoomInternal().ConfigureAwait(false);
+ });
+ }
+
+ protected abstract Task LeaveRoomInternal();
+
+ ///
+ /// Change the current settings.
+ ///
+ ///
+ /// A room must be joined for this to have any effect.
+ ///
+ /// The new room name, if any.
+ /// The new room playlist item, if any.
+ public Task ChangeSettings(Optional name = default, Optional item = default)
+ {
+ if (Room == null)
+ throw new InvalidOperationException("Must be joined to a match to change settings.");
+
+ // A dummy playlist item filled with the current room settings (except mods).
+ var existingPlaylistItem = new PlaylistItem
+ {
+ Beatmap =
+ {
+ Value = new BeatmapInfo
+ {
+ OnlineBeatmapID = Room.Settings.BeatmapID,
+ MD5Hash = Room.Settings.BeatmapChecksum
+ }
+ },
+ RulesetID = Room.Settings.RulesetID
+ };
+
+ return ChangeSettings(new MultiplayerRoomSettings
+ {
+ Name = name.GetOr(Room.Settings.Name),
+ BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
+ BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
+ RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
+ RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
+ AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
+ });
+ }
+
+ ///
+ /// Toggles the 's ready state.
+ ///
+ /// If a toggle of ready state is not valid at this time.
+ public async Task ToggleReady()
+ {
+ var localUser = LocalUser;
+
+ if (localUser == null)
+ return;
+
+ switch (localUser.State)
+ {
+ case MultiplayerUserState.Idle:
+ await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
+ return;
+
+ case MultiplayerUserState.Ready:
+ await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
+ return;
+
+ default:
+ throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
+ }
+ }
+
+ ///
+ /// Toggles the 's spectating state.
+ ///
+ /// If a toggle of the spectating state is not valid at this time.
+ public async Task ToggleSpectate()
+ {
+ var localUser = LocalUser;
+
+ if (localUser == null)
+ return;
+
+ switch (localUser.State)
+ {
+ case MultiplayerUserState.Idle:
+ case MultiplayerUserState.Ready:
+ await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
+ return;
+
+ case MultiplayerUserState.Spectating:
+ await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
+ return;
+
+ default:
+ throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
+ }
+ }
+
+ public abstract Task TransferHost(int userId);
+
+ public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
+
+ public abstract Task ChangeState(MultiplayerUserState newState);
+
+ public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
+
+ ///
+ /// Change the local user's mods in the currently joined room.
+ ///
+ /// The proposed new mods, excluding any required by the room itself.
+ public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
+
+ public abstract Task ChangeUserMods(IEnumerable newMods);
+
+ public abstract Task StartMatch();
+
+ Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
+ {
+ if (Room == null)
return Task.CompletedTask;
- return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ Debug.Assert(apiRoom != null);
+
+ Room.State = state;
+
+ switch (state)
+ {
+ case MultiplayerRoomState.Open:
+ apiRoom.Status.Value = new RoomStatusOpen();
+ break;
+
+ case MultiplayerRoomState.Playing:
+ apiRoom.Status.Value = new RoomStatusPlaying();
+ break;
+
+ case MultiplayerRoomState.Closed:
+ apiRoom.Status.Value = new RoomStatusEnded();
+ break;
+ }
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
}
- public override Task ChangeSettings(MultiplayerRoomSettings settings)
+ async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
{
- if (!IsConnected.Value)
+ if (Room == null)
+ return;
+
+ await PopulateUser(user).ConfigureAwait(false);
+
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ // for sanity, ensure that there can be no duplicate users in the room user list.
+ if (Room.Users.Any(existing => existing.UserID == user.UserID))
+ return;
+
+ Room.Users.Add(user);
+
+ RoomUpdated?.Invoke();
+ }, false);
+ }
+
+ Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
+ {
+ if (Room == null)
return Task.CompletedTask;
- return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ Room.Users.Remove(user);
+ CurrentMatchPlayingUserIds.Remove(user.UserID);
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
}
- public override Task ChangeState(MultiplayerUserState newState)
+ Task IMultiplayerClient.HostChanged(int userId)
{
- if (!IsConnected.Value)
+ if (Room == null)
return Task.CompletedTask;
- return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ Debug.Assert(apiRoom != null);
+
+ var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
+
+ Room.Host = user;
+ apiRoom.Host.Value = user?.User;
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
}
- public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
+ Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
{
- if (!IsConnected.Value)
+ updateLocalRoomSettings(newSettings);
+ return Task.CompletedTask;
+ }
+
+ Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
+ {
+ if (Room == null)
return Task.CompletedTask;
- return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ Room.Users.Single(u => u.UserID == userId).State = state;
+
+ updateUserPlayingState(userId, state);
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
}
- public override Task ChangeUserMods(IEnumerable newMods)
+ Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
{
- if (!IsConnected.Value)
+ if (Room == null)
return Task.CompletedTask;
- return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
+ Scheduler.Add(() =>
+ {
+ var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
+
+ // errors here are not critical - beatmap availability state is mostly for display.
+ if (user == null)
+ return;
+
+ user.BeatmapAvailability = beatmapAvailability;
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
}
- public override Task StartMatch()
+ public Task UserModsChanged(int userId, IEnumerable mods)
{
- if (!IsConnected.Value)
+ if (Room == null)
return Task.CompletedTask;
- return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
+ Scheduler.Add(() =>
+ {
+ var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
+
+ // errors here are not critical - user mods are mostly for display.
+ if (user == null)
+ return;
+
+ user.Mods = mods;
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
}
- protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
+ Task IMultiplayerClient.LoadRequested()
{
- var tcs = new TaskCompletionSource();
- var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
+ if (Room == null)
+ return Task.CompletedTask;
- req.Success += res =>
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ LoadRequested?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
+ }
+
+ Task IMultiplayerClient.MatchStarted()
+ {
+ if (Room == null)
+ return Task.CompletedTask;
+
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ MatchStarted?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
+ }
+
+ Task IMultiplayerClient.ResultsReady()
+ {
+ if (Room == null)
+ return Task.CompletedTask;
+
+ Scheduler.Add(() =>
+ {
+ if (Room == null)
+ return;
+
+ ResultsReady?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Populates the for a given .
+ ///
+ /// The to populate.
+ protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
+
+ ///
+ /// Updates the local room settings with the given .
+ ///
+ ///
+ /// This updates both the joined and the respective API .
+ ///
+ /// The new to update from.
+ /// The to cancel the update.
+ private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
+ {
+ if (Room == null)
+ return;
+
+ Debug.Assert(apiRoom != null);
+
+ // Update a few properties of the room instantaneously.
+ Room.Settings = settings;
+ apiRoom.Name.Value = Room.Settings.Name;
+
+ // The current item update is delayed until an online beatmap lookup (below) succeeds.
+ // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
+ CurrentMatchPlayingItem.Value = null;
+
+ RoomUpdated?.Invoke();
+
+ GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
+ {
+ if (cancellationToken.IsCancellationRequested)
+ return;
+
+ updatePlaylist(settings, set.Result);
+ }), TaskContinuationOptions.OnlyOnRanToCompletion);
+ }, cancellationToken);
+
+ private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
+ {
+ if (Room == null || !Room.Settings.Equals(settings))
+ return;
+
+ Debug.Assert(apiRoom != null);
+
+ var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
+ beatmap.MD5Hash = settings.BeatmapChecksum;
+
+ var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
+ var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
+ var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
+
+ // Try to retrieve the existing playlist item from the API room.
+ var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
+
+ if (playlistItem != null)
+ updateItem(playlistItem);
+ else
+ {
+ // An existing playlist item does not exist, so append a new one.
+ updateItem(playlistItem = new PlaylistItem());
+ apiRoom.Playlist.Add(playlistItem);
+ }
+
+ CurrentMatchPlayingItem.Value = playlistItem;
+
+ void updateItem(PlaylistItem item)
+ {
+ item.ID = settings.PlaylistItemId;
+ item.Beatmap.Value = beatmap;
+ item.Ruleset.Value = ruleset.RulesetInfo;
+ item.RequiredMods.Clear();
+ item.RequiredMods.AddRange(mods);
+ item.AllowedMods.Clear();
+ item.AllowedMods.AddRange(allowedMods);
+ }
+ }
+
+ ///
+ /// Retrieves a from an online source.
+ ///
+ /// The beatmap set ID.
+ /// A token to cancel the request.
+ /// The retrieval task.
+ protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
+
+ ///
+ /// For the provided user ID, update whether the user is included in .
+ ///
+ /// The user's ID.
+ /// The new state of the user.
+ private void updateUserPlayingState(int userId, MultiplayerUserState state)
+ {
+ bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
+ bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
+
+ if (isPlaying == wasPlaying)
+ return;
+
+ if (isPlaying)
+ CurrentMatchPlayingUserIds.Add(userId);
+ else
+ CurrentMatchPlayingUserIds.Remove(userId);
+ }
+
+ private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
+ {
+ var tcs = new TaskCompletionSource();
+
+ Scheduler.Add(() =>
{
if (cancellationToken.IsCancellationRequested)
{
@@ -136,20 +625,18 @@ namespace osu.Game.Online.Multiplayer
return;
}
- tcs.SetResult(res.ToBeatmapSet(Rulesets));
- };
-
- req.Failure += e => tcs.SetException(e);
-
- API.Queue(req);
+ try
+ {
+ action();
+ tcs.SetResult(true);
+ }
+ catch (Exception ex)
+ {
+ tcs.SetException(ex);
+ }
+ });
return tcs.Task;
}
-
- protected override void Dispose(bool isDisposing)
- {
- base.Dispose(isDisposing);
- connector?.Dispose();
- }
}
}
diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs
new file mode 100644
index 0000000000..cf1e18e059
--- /dev/null
+++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs
@@ -0,0 +1,158 @@
+// 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.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR.Client;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Online.Rooms;
+
+namespace osu.Game.Online.Multiplayer
+{
+ ///
+ /// A with online connectivity.
+ ///
+ public class OnlineMultiplayerClient : MultiplayerClient
+ {
+ private readonly string endpoint;
+
+ private IHubClientConnector? connector;
+
+ public override IBindable IsConnected { get; } = new BindableBool();
+
+ private HubConnection? connection => connector?.CurrentConnection;
+
+ public OnlineMultiplayerClient(EndpointConfiguration endpoints)
+ {
+ endpoint = endpoints.MultiplayerEndpointUrl;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(IAPIProvider api)
+ {
+ connector = api.GetHubConnector(nameof(OnlineMultiplayerClient), endpoint);
+
+ if (connector != null)
+ {
+ connector.ConfigureConnection = connection =>
+ {
+ // this is kind of SILLY
+ // https://github.com/dotnet/aspnetcore/issues/15198
+ connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
+ connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
+ connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
+ connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
+ connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
+ connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
+ connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
+ connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
+ connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
+ connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
+ connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
+ };
+
+ IsConnected.BindTo(connector.IsConnected);
+ }
+ }
+
+ protected override Task JoinRoom(long roomId)
+ {
+ if (!IsConnected.Value)
+ return Task.FromCanceled(new CancellationToken(true));
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId);
+ }
+
+ protected override Task LeaveRoomInternal()
+ {
+ if (!IsConnected.Value)
+ return Task.FromCanceled(new CancellationToken(true));
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
+ }
+
+ public override Task TransferHost(int userId)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
+ }
+
+ public override Task ChangeSettings(MultiplayerRoomSettings settings)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
+ }
+
+ public override Task ChangeState(MultiplayerUserState newState)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
+ }
+
+ public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
+ }
+
+ public override Task ChangeUserMods(IEnumerable newMods)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
+ }
+
+ public override Task StartMatch()
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
+ }
+
+ protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
+ {
+ var tcs = new TaskCompletionSource();
+ var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
+
+ req.Success += res =>
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ tcs.SetCanceled();
+ return;
+ }
+
+ tcs.SetResult(res.ToBeatmapSet(Rulesets));
+ };
+
+ req.Failure += e => tcs.SetException(e);
+
+ API.Queue(req);
+
+ return tcs.Task;
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+ connector?.Dispose();
+ }
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
deleted file mode 100644
index 7fe48d54b1..0000000000
--- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
+++ /dev/null
@@ -1,642 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-#nullable enable
-
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Extensions.ObjectExtensions;
-using osu.Framework.Graphics;
-using osu.Framework.Logging;
-using osu.Game.Beatmaps;
-using osu.Game.Database;
-using osu.Game.Online.API;
-using osu.Game.Online.Rooms;
-using osu.Game.Online.Rooms.RoomStatuses;
-using osu.Game.Rulesets;
-using osu.Game.Rulesets.Mods;
-using osu.Game.Users;
-using osu.Game.Utils;
-
-namespace osu.Game.Online.Multiplayer
-{
- public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
- {
- ///
- /// Invoked when any change occurs to the multiplayer room.
- ///
- public event Action? RoomUpdated;
-
- ///
- /// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
- ///
- public event Action? LoadRequested;
-
- ///
- /// Invoked when the multiplayer server requests gameplay to be started.
- ///
- public event Action? MatchStarted;
-
- ///
- /// Invoked when the multiplayer server has finished collating results.
- ///
- public event Action? ResultsReady;
-
- ///
- /// Whether the is currently connected.
- /// This is NOT thread safe and usage should be scheduled.
- ///
- public abstract IBindable IsConnected { get; }
-
- ///
- /// The joined .
- ///
- public MultiplayerRoom? Room { get; private set; }
-
- ///
- /// The users in the joined which are participating in the current gameplay loop.
- ///
- public readonly BindableList CurrentMatchPlayingUserIds = new BindableList();
-
- public readonly Bindable CurrentMatchPlayingItem = new Bindable();
-
- ///
- /// The corresponding to the local player, if available.
- ///
- public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
-
- ///
- /// Whether the is the host in .
- ///
- public bool IsHost
- {
- get
- {
- var localUser = LocalUser;
- return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
- }
- }
-
- [Resolved]
- protected IAPIProvider API { get; private set; } = null!;
-
- [Resolved]
- protected RulesetStore Rulesets { get; private set; } = null!;
-
- [Resolved]
- private UserLookupCache userLookupCache { get; set; } = null!;
-
- private Room? apiRoom;
-
- [BackgroundDependencyLoader]
- private void load()
- {
- IsConnected.BindValueChanged(connected =>
- {
- // clean up local room state on server disconnect.
- if (!connected.NewValue && Room != null)
- {
- Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
- LeaveRoom();
- }
- });
- }
-
- private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
- private CancellationTokenSource? joinCancellationSource;
-
- ///
- /// Joins the for a given API .
- ///
- /// The API .
- public async Task JoinRoom(Room room)
- {
- var cancellationSource = joinCancellationSource = new CancellationTokenSource();
-
- await joinOrLeaveTaskChain.Add(async () =>
- {
- if (Room != null)
- throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
-
- Debug.Assert(room.RoomID.Value != null);
-
- // Join the server-side room.
- var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
- Debug.Assert(joinedRoom != null);
-
- // Populate users.
- Debug.Assert(joinedRoom.Users != null);
- await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
-
- // Update the stored room (must be done on update thread for thread-safety).
- await scheduleAsync(() =>
- {
- Room = joinedRoom;
- apiRoom = room;
- foreach (var user in joinedRoom.Users)
- updateUserPlayingState(user.UserID, user.State);
- }, cancellationSource.Token).ConfigureAwait(false);
-
- // Update room settings.
- await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
- }, cancellationSource.Token).ConfigureAwait(false);
- }
-
- ///
- /// Joins the with a given ID.
- ///
- /// The room ID.
- /// The joined .
- protected abstract Task JoinRoom(long roomId);
-
- public Task LeaveRoom()
- {
- // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
- // This includes the setting of Room itself along with the initial update of the room settings on join.
- joinCancellationSource?.Cancel();
-
- // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
- // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
- // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
- var scheduledReset = scheduleAsync(() =>
- {
- apiRoom = null;
- Room = null;
- CurrentMatchPlayingUserIds.Clear();
-
- RoomUpdated?.Invoke();
- });
-
- return joinOrLeaveTaskChain.Add(async () =>
- {
- await scheduledReset.ConfigureAwait(false);
- await LeaveRoomInternal().ConfigureAwait(false);
- });
- }
-
- protected abstract Task LeaveRoomInternal();
-
- ///
- /// Change the current settings.
- ///
- ///
- /// A room must be joined for this to have any effect.
- ///
- /// The new room name, if any.
- /// The new room playlist item, if any.
- public Task ChangeSettings(Optional name = default, Optional item = default)
- {
- if (Room == null)
- throw new InvalidOperationException("Must be joined to a match to change settings.");
-
- // A dummy playlist item filled with the current room settings (except mods).
- var existingPlaylistItem = new PlaylistItem
- {
- Beatmap =
- {
- Value = new BeatmapInfo
- {
- OnlineBeatmapID = Room.Settings.BeatmapID,
- MD5Hash = Room.Settings.BeatmapChecksum
- }
- },
- RulesetID = Room.Settings.RulesetID
- };
-
- return ChangeSettings(new MultiplayerRoomSettings
- {
- Name = name.GetOr(Room.Settings.Name),
- BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
- BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
- RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
- RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
- AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
- });
- }
-
- ///
- /// Toggles the 's ready state.
- ///
- /// If a toggle of ready state is not valid at this time.
- public async Task ToggleReady()
- {
- var localUser = LocalUser;
-
- if (localUser == null)
- return;
-
- switch (localUser.State)
- {
- case MultiplayerUserState.Idle:
- await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
- return;
-
- case MultiplayerUserState.Ready:
- await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
- return;
-
- default:
- throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
- }
- }
-
- ///
- /// Toggles the 's spectating state.
- ///
- /// If a toggle of the spectating state is not valid at this time.
- public async Task ToggleSpectate()
- {
- var localUser = LocalUser;
-
- if (localUser == null)
- return;
-
- switch (localUser.State)
- {
- case MultiplayerUserState.Idle:
- case MultiplayerUserState.Ready:
- await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
- return;
-
- case MultiplayerUserState.Spectating:
- await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
- return;
-
- default:
- throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
- }
- }
-
- public abstract Task TransferHost(int userId);
-
- public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
-
- public abstract Task ChangeState(MultiplayerUserState newState);
-
- public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
-
- ///
- /// Change the local user's mods in the currently joined room.
- ///
- /// The proposed new mods, excluding any required by the room itself.
- public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
-
- public abstract Task ChangeUserMods(IEnumerable newMods);
-
- public abstract Task StartMatch();
-
- Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- Debug.Assert(apiRoom != null);
-
- Room.State = state;
-
- switch (state)
- {
- case MultiplayerRoomState.Open:
- apiRoom.Status.Value = new RoomStatusOpen();
- break;
-
- case MultiplayerRoomState.Playing:
- apiRoom.Status.Value = new RoomStatusPlaying();
- break;
-
- case MultiplayerRoomState.Closed:
- apiRoom.Status.Value = new RoomStatusEnded();
- break;
- }
-
- RoomUpdated?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
- {
- if (Room == null)
- return;
-
- await PopulateUser(user).ConfigureAwait(false);
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- // for sanity, ensure that there can be no duplicate users in the room user list.
- if (Room.Users.Any(existing => existing.UserID == user.UserID))
- return;
-
- Room.Users.Add(user);
-
- RoomUpdated?.Invoke();
- }, false);
- }
-
- Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- Room.Users.Remove(user);
- CurrentMatchPlayingUserIds.Remove(user.UserID);
-
- RoomUpdated?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.HostChanged(int userId)
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- Debug.Assert(apiRoom != null);
-
- var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
-
- Room.Host = user;
- apiRoom.Host.Value = user?.User;
-
- RoomUpdated?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
- {
- updateLocalRoomSettings(newSettings);
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- Room.Users.Single(u => u.UserID == userId).State = state;
-
- updateUserPlayingState(userId, state);
-
- RoomUpdated?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
-
- // errors here are not critical - beatmap availability state is mostly for display.
- if (user == null)
- return;
-
- user.BeatmapAvailability = beatmapAvailability;
-
- RoomUpdated?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- public Task UserModsChanged(int userId, IEnumerable mods)
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
-
- // errors here are not critical - user mods are mostly for display.
- if (user == null)
- return;
-
- user.Mods = mods;
-
- RoomUpdated?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.LoadRequested()
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- LoadRequested?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.MatchStarted()
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- MatchStarted?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- Task IMultiplayerClient.ResultsReady()
- {
- if (Room == null)
- return Task.CompletedTask;
-
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
-
- ResultsReady?.Invoke();
- }, false);
-
- return Task.CompletedTask;
- }
-
- ///
- /// Populates the for a given .
- ///
- /// The to populate.
- protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
-
- ///
- /// Updates the local room settings with the given .
- ///
- ///
- /// This updates both the joined and the respective API .
- ///
- /// The new to update from.
- /// The to cancel the update.
- private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
- {
- if (Room == null)
- return;
-
- Debug.Assert(apiRoom != null);
-
- // Update a few properties of the room instantaneously.
- Room.Settings = settings;
- apiRoom.Name.Value = Room.Settings.Name;
-
- // The current item update is delayed until an online beatmap lookup (below) succeeds.
- // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
- CurrentMatchPlayingItem.Value = null;
-
- RoomUpdated?.Invoke();
-
- GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
- {
- if (cancellationToken.IsCancellationRequested)
- return;
-
- updatePlaylist(settings, set.Result);
- }), TaskContinuationOptions.OnlyOnRanToCompletion);
- }, cancellationToken);
-
- private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
- {
- if (Room == null || !Room.Settings.Equals(settings))
- return;
-
- Debug.Assert(apiRoom != null);
-
- var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
- beatmap.MD5Hash = settings.BeatmapChecksum;
-
- var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
- var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
- var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
-
- // Try to retrieve the existing playlist item from the API room.
- var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
-
- if (playlistItem != null)
- updateItem(playlistItem);
- else
- {
- // An existing playlist item does not exist, so append a new one.
- updateItem(playlistItem = new PlaylistItem());
- apiRoom.Playlist.Add(playlistItem);
- }
-
- CurrentMatchPlayingItem.Value = playlistItem;
-
- void updateItem(PlaylistItem item)
- {
- item.ID = settings.PlaylistItemId;
- item.Beatmap.Value = beatmap;
- item.Ruleset.Value = ruleset.RulesetInfo;
- item.RequiredMods.Clear();
- item.RequiredMods.AddRange(mods);
- item.AllowedMods.Clear();
- item.AllowedMods.AddRange(allowedMods);
- }
- }
-
- ///
- /// Retrieves a from an online source.
- ///
- /// The beatmap set ID.
- /// A token to cancel the request.
- /// The retrieval task.
- protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
-
- ///
- /// For the provided user ID, update whether the user is included in .
- ///
- /// The user's ID.
- /// The new state of the user.
- private void updateUserPlayingState(int userId, MultiplayerUserState state)
- {
- bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
- bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
-
- if (isPlaying == wasPlaying)
- return;
-
- if (isPlaying)
- CurrentMatchPlayingUserIds.Add(userId);
- else
- CurrentMatchPlayingUserIds.Remove(userId);
- }
-
- private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
- {
- var tcs = new TaskCompletionSource();
-
- Scheduler.Add(() =>
- {
- if (cancellationToken.IsCancellationRequested)
- {
- tcs.SetCanceled();
- return;
- }
-
- try
- {
- action();
- tcs.SetResult(true);
- }
- catch (Exception ex)
- {
- tcs.SetException(ex);
- }
- });
-
- return tcs.Task;
- }
- }
-}
diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs
new file mode 100644
index 0000000000..753796158e
--- /dev/null
+++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs
@@ -0,0 +1,89 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.SignalR.Client;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Game.Online.API;
+
+namespace osu.Game.Online.Spectator
+{
+ public class OnlineSpectatorClient : SpectatorClient
+ {
+ private readonly string endpoint;
+
+ private IHubClientConnector? connector;
+
+ public override IBindable IsConnected { get; } = new BindableBool();
+
+ private HubConnection? connection => connector?.CurrentConnection;
+
+ public OnlineSpectatorClient(EndpointConfiguration endpoints)
+ {
+ endpoint = endpoints.SpectatorEndpointUrl;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(IAPIProvider api)
+ {
+ connector = api.GetHubConnector(nameof(SpectatorClient), endpoint);
+
+ if (connector != null)
+ {
+ connector.ConfigureConnection = connection =>
+ {
+ // until strong typed client support is added, each method must be manually bound
+ // (see https://github.com/dotnet/aspnetcore/issues/15198)
+ connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
+ connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
+ connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
+ };
+
+ IsConnected.BindTo(connector.IsConnected);
+ }
+ }
+
+ protected override Task BeginPlayingInternal(SpectatorState state)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), state);
+ }
+
+ protected override Task SendFramesInternal(FrameDataBundle data)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
+ }
+
+ protected override Task EndPlayingInternal(SpectatorState state)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state);
+ }
+
+ protected override Task WatchUserInternal(int userId)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
+ }
+
+ protected override Task StopWatchingUserInternal(int userId)
+ {
+ if (!IsConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
+ }
+ }
+}
diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs
new file mode 100644
index 0000000000..a4fc963328
--- /dev/null
+++ b/osu.Game/Online/Spectator/SpectatorClient.cs
@@ -0,0 +1,261 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Development;
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
+using osu.Game.Online.API;
+using osu.Game.Replays.Legacy;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Replays;
+using osu.Game.Rulesets.Replays.Types;
+using osu.Game.Scoring;
+using osu.Game.Screens.Play;
+
+namespace osu.Game.Online.Spectator
+{
+ public abstract class SpectatorClient : Component, ISpectatorClient
+ {
+ ///
+ /// The maximum milliseconds between frame bundle sends.
+ ///
+ public const double TIME_BETWEEN_SENDS = 200;
+
+ ///
+ /// Whether the is currently connected.
+ /// This is NOT thread safe and usage should be scheduled.
+ ///
+ public abstract IBindable IsConnected { get; }
+
+ private readonly List watchingUsers = new List();
+
+ public IBindableList PlayingUsers => playingUsers;
+ private readonly BindableList playingUsers = new BindableList();
+
+ public IBindableDictionary PlayingUserStates => playingUserStates;
+ private readonly BindableDictionary playingUserStates = new BindableDictionary();
+
+ private IBeatmap? currentBeatmap;
+
+ private Score? currentScore;
+
+ [Resolved]
+ private IBindable currentRuleset { get; set; } = null!;
+
+ [Resolved]
+ private IBindable> currentMods { get; set; } = null!;
+
+ private readonly SpectatorState currentState = new SpectatorState();
+
+ ///
+ /// Whether the local user is playing.
+ ///
+ protected bool IsPlaying { get; private set; }
+
+ ///
+ /// Called whenever new frames arrive from the server.
+ ///
+ public event Action? OnNewFrames;
+
+ ///
+ /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session.
+ ///
+ public event Action? OnUserBeganPlaying;
+
+ ///
+ /// Called whenever a user finishes a play session.
+ ///
+ public event Action? OnUserFinishedPlaying;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ IsConnected.BindValueChanged(connected => Schedule(() =>
+ {
+ if (connected.NewValue)
+ {
+ // get all the users that were previously being watched
+ int[] users = watchingUsers.ToArray();
+ watchingUsers.Clear();
+
+ // resubscribe to watched users.
+ foreach (var userId in users)
+ WatchUser(userId);
+
+ // re-send state in case it wasn't received
+ if (IsPlaying)
+ BeginPlayingInternal(currentState);
+ }
+ else
+ {
+ playingUsers.Clear();
+ playingUserStates.Clear();
+ }
+ }), true);
+ }
+
+ Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
+ {
+ Schedule(() =>
+ {
+ if (!playingUsers.Contains(userId))
+ playingUsers.Add(userId);
+
+ // 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);
+ });
+
+ return Task.CompletedTask;
+ }
+
+ Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
+ {
+ Schedule(() =>
+ {
+ playingUsers.Remove(userId);
+ playingUserStates.Remove(userId);
+
+ OnUserFinishedPlaying?.Invoke(userId, state);
+ });
+
+ return Task.CompletedTask;
+ }
+
+ Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
+ {
+ Schedule(() => OnNewFrames?.Invoke(userId, data));
+
+ return Task.CompletedTask;
+ }
+
+ public void BeginPlaying(GameplayBeatmap beatmap, Score score)
+ {
+ Debug.Assert(ThreadSafety.IsUpdateThread);
+
+ if (IsPlaying)
+ throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
+
+ IsPlaying = true;
+
+ // transfer state at point of beginning play
+ currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID;
+ currentState.RulesetID = currentRuleset.Value.ID;
+ currentState.Mods = currentMods.Value.Select(m => new APIMod(m));
+
+ currentBeatmap = beatmap.PlayableBeatmap;
+ currentScore = score;
+
+ BeginPlayingInternal(currentState);
+ }
+
+ public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data);
+
+ public void EndPlaying()
+ {
+ // This method is most commonly called via Dispose(), which is asynchronous.
+ // Todo: This should not be a thing, but requires framework changes.
+ Schedule(() =>
+ {
+ if (!IsPlaying)
+ return;
+
+ IsPlaying = false;
+ currentBeatmap = null;
+
+ EndPlayingInternal(currentState);
+ });
+ }
+
+ public void WatchUser(int userId)
+ {
+ Debug.Assert(ThreadSafety.IsUpdateThread);
+
+ if (watchingUsers.Contains(userId))
+ return;
+
+ watchingUsers.Add(userId);
+
+ WatchUserInternal(userId);
+ }
+
+ public void StopWatchingUser(int userId)
+ {
+ // This method is most commonly called via Dispose(), which is asynchronous.
+ // Todo: This should not be a thing, but requires framework changes.
+ Schedule(() =>
+ {
+ watchingUsers.Remove(userId);
+ playingUserStates.Remove(userId);
+ StopWatchingUserInternal(userId);
+ });
+ }
+
+ protected abstract Task BeginPlayingInternal(SpectatorState state);
+
+ protected abstract Task SendFramesInternal(FrameDataBundle data);
+
+ protected abstract Task EndPlayingInternal(SpectatorState state);
+
+ protected abstract Task WatchUserInternal(int userId);
+
+ protected abstract Task StopWatchingUserInternal(int userId);
+
+ private readonly Queue pendingFrames = new Queue();
+
+ private double lastSendTime;
+
+ private Task? lastSend;
+
+ private const int max_pending_frames = 30;
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS)
+ purgePendingFrames();
+ }
+
+ public void HandleFrame(ReplayFrame frame)
+ {
+ Debug.Assert(ThreadSafety.IsUpdateThread);
+
+ if (frame is IConvertibleReplayFrame convertible)
+ pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap));
+
+ if (pendingFrames.Count > max_pending_frames)
+ purgePendingFrames();
+ }
+
+ private void purgePendingFrames()
+ {
+ if (lastSend?.IsCompleted == false)
+ return;
+
+ var frames = pendingFrames.ToArray();
+
+ pendingFrames.Clear();
+
+ Debug.Assert(currentScore != null);
+
+ SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames));
+
+ lastSendTime = Time.Current;
+ }
+ }
+}
diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
deleted file mode 100644
index ec6d1bf9d8..0000000000
--- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
+++ /dev/null
@@ -1,323 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading.Tasks;
-using JetBrains.Annotations;
-using Microsoft.AspNetCore.SignalR.Client;
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Game.Beatmaps;
-using osu.Game.Online.API;
-using osu.Game.Replays.Legacy;
-using osu.Game.Rulesets;
-using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Replays;
-using osu.Game.Rulesets.Replays.Types;
-using osu.Game.Scoring;
-using osu.Game.Screens.Play;
-
-namespace osu.Game.Online.Spectator
-{
- public class SpectatorStreamingClient : Component, ISpectatorClient
- {
- ///
- /// The maximum milliseconds between frame bundle sends.
- ///
- public const double TIME_BETWEEN_SENDS = 200;
-
- private readonly string endpoint;
-
- [CanBeNull]
- private IHubClientConnector connector;
-
- private readonly IBindable isConnected = new BindableBool();
-
- private HubConnection connection => connector?.CurrentConnection;
-
- private readonly List watchingUsers = new List();
-
- private readonly object userLock = new object();
-
- public IBindableList PlayingUsers => playingUsers;
-
- private readonly BindableList playingUsers = new BindableList();
-
- private readonly Dictionary playingUserStates = new Dictionary();
-
- [CanBeNull]
- private IBeatmap currentBeatmap;
-
- [CanBeNull]
- private Score currentScore;
-
- [Resolved]
- private IBindable currentRuleset { get; set; }
-
- [Resolved]
- private IBindable> currentMods { get; set; }
-
- private readonly SpectatorState currentState = new SpectatorState();
-
- private bool isPlaying;
-
- ///
- /// Called whenever new frames arrive from the server.
- ///
- public event Action OnNewFrames;
-
- ///
- /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session.
- ///
- public event Action OnUserBeganPlaying;
-
- ///
- /// Called whenever a user finishes a play session.
- ///
- public event Action OnUserFinishedPlaying;
-
- public SpectatorStreamingClient(EndpointConfiguration endpoints)
- {
- endpoint = endpoints.SpectatorEndpointUrl;
- }
-
- [BackgroundDependencyLoader]
- private void load(IAPIProvider api)
- {
- connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint);
-
- if (connector != null)
- {
- connector.ConfigureConnection = connection =>
- {
- // until strong typed client support is added, each method must be manually bound
- // (see https://github.com/dotnet/aspnetcore/issues/15198)
- connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
- connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
- connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying);
- };
-
- isConnected.BindTo(connector.IsConnected);
- isConnected.BindValueChanged(connected =>
- {
- if (connected.NewValue)
- {
- // get all the users that were previously being watched
- int[] users;
-
- lock (userLock)
- {
- users = watchingUsers.ToArray();
- watchingUsers.Clear();
- }
-
- // resubscribe to watched users.
- foreach (var userId in users)
- WatchUser(userId);
-
- // re-send state in case it wasn't received
- if (isPlaying)
- beginPlaying();
- }
- else
- {
- lock (userLock)
- {
- playingUsers.Clear();
- playingUserStates.Clear();
- }
- }
- }, true);
- }
- }
-
- Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state)
- {
- lock (userLock)
- {
- if (!playingUsers.Contains(userId))
- playingUsers.Add(userId);
-
- // 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);
-
- return Task.CompletedTask;
- }
-
- Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
- {
- lock (userLock)
- {
- playingUsers.Remove(userId);
- playingUserStates.Remove(userId);
- }
-
- OnUserFinishedPlaying?.Invoke(userId, state);
-
- return Task.CompletedTask;
- }
-
- Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
- {
- OnNewFrames?.Invoke(userId, data);
-
- return Task.CompletedTask;
- }
-
- public void BeginPlaying(GameplayBeatmap beatmap, Score score)
- {
- if (isPlaying)
- throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
-
- isPlaying = true;
-
- // transfer state at point of beginning play
- currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID;
- currentState.RulesetID = currentRuleset.Value.ID;
- currentState.Mods = currentMods.Value.Select(m => new APIMod(m));
-
- currentBeatmap = beatmap.PlayableBeatmap;
- currentScore = score;
-
- beginPlaying();
- }
-
- private void beginPlaying()
- {
- Debug.Assert(isPlaying);
-
- if (!isConnected.Value) return;
-
- connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState);
- }
-
- public void SendFrames(FrameDataBundle data)
- {
- if (!isConnected.Value) return;
-
- lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data);
- }
-
- public void EndPlaying()
- {
- isPlaying = false;
- currentBeatmap = null;
-
- if (!isConnected.Value) return;
-
- connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
- }
-
- public virtual void WatchUser(int userId)
- {
- lock (userLock)
- {
- if (watchingUsers.Contains(userId))
- return;
-
- watchingUsers.Add(userId);
-
- if (!isConnected.Value)
- return;
- }
-
- connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
- }
-
- public virtual void StopWatchingUser(int userId)
- {
- lock (userLock)
- {
- watchingUsers.Remove(userId);
-
- if (!isConnected.Value)
- return;
- }
-
- connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId);
- }
-
- private readonly Queue pendingFrames = new Queue();
-
- private double lastSendTime;
-
- private Task lastSend;
-
- private const int max_pending_frames = 30;
-
- protected override void Update()
- {
- base.Update();
-
- if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS)
- purgePendingFrames();
- }
-
- public void HandleFrame(ReplayFrame frame)
- {
- if (frame is IConvertibleReplayFrame convertible)
- pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap));
-
- if (pendingFrames.Count > max_pending_frames)
- purgePendingFrames();
- }
-
- private void purgePendingFrames()
- {
- if (lastSend?.IsCompleted == false)
- return;
-
- var frames = pendingFrames.ToArray();
-
- pendingFrames.Clear();
-
- Debug.Assert(currentScore != null);
-
- SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames));
-
- lastSendTime = Time.Current;
- }
-
- ///
- /// Attempts to retrieve the for a currently-playing user.
- ///
- /// The user.
- /// The current for the user, if they're playing. null if the user is not playing.
- /// true if successful (the user is playing), false otherwise.
- public bool TryGetPlayingUserState(int userId, out SpectatorState state)
- {
- lock (userLock)
- return playingUserStates.TryGetValue(userId, out state);
- }
-
- ///
- /// Bind an action to with the option of running the bound action once immediately.
- ///
- /// The action to perform when a user begins playing.
- /// Whether the action provided in should be run once immediately for all users currently playing.
- public void BindUserBeganPlaying(Action callback, bool runOnceImmediately = false)
- {
- // The lock is taken before the event is subscribed to to prevent doubling of events.
- lock (userLock)
- {
- OnUserBeganPlaying += callback;
-
- if (!runOnceImmediately)
- return;
-
- foreach (var (userId, state) in playingUserStates)
- callback(userId, state);
- }
- }
- }
-}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index f860cd8dd2..06e0b6e9bf 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -100,6 +100,9 @@ namespace osu.Game
[Cached]
private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender();
+ [Cached]
+ private readonly StableImportManager stableImportManager = new StableImportManager();
+
[Cached]
private readonly ScreenshotManager screenshotManager = new ScreenshotManager();
@@ -566,14 +569,11 @@ namespace osu.Game
// todo: all archive managers should be able to be looped here.
SkinManager.PostNotification = n => notifications.Post(n);
- SkinManager.GetStableStorage = GetStorageForStableInstall;
BeatmapManager.PostNotification = n => notifications.Post(n);
- BeatmapManager.GetStableStorage = GetStorageForStableInstall;
BeatmapManager.PresentImport = items => PresentBeatmap(items.First());
ScoreManager.PostNotification = n => notifications.Post(n);
- ScoreManager.GetStableStorage = GetStorageForStableInstall;
ScoreManager.PresentImport = items => PresentScore(items.First());
// make config aware of how to lookup skins for on-screen display purposes.
@@ -690,10 +690,10 @@ namespace osu.Game
loadComponentSingleFile(new CollectionManager(Storage)
{
PostNotification = n => notifications.Post(n),
- GetStableStorage = GetStorageForStableInstall
}, Add, true);
loadComponentSingleFile(difficultyRecommender, Add);
+ loadComponentSingleFile(stableImportManager, Add);
loadComponentSingleFile(screenshotManager, Add);
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index fbe4022cc1..3c143c1db9 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -85,8 +85,8 @@ namespace osu.Game
protected IAPIProvider API;
- private SpectatorStreamingClient spectatorStreaming;
- private StatefulMultiplayerClient multiplayerClient;
+ private SpectatorClient spectatorClient;
+ private MultiplayerClient multiplayerClient;
protected MenuCursorContainer MenuCursorContainer;
@@ -240,8 +240,8 @@ namespace osu.Game
dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash));
- dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints));
- dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints));
+ dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints));
+ dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints));
var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures);
@@ -313,7 +313,7 @@ namespace osu.Game
// add api components to hierarchy.
if (API is APIAccess apiAccess)
AddInternal(apiAccess);
- AddInternal(spectatorStreaming);
+ AddInternal(spectatorClient);
AddInternal(multiplayerClient);
AddInternal(RulesetConfigCache);
diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs
index b2096968fe..3d46e9ed94 100644
--- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs
+++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs
@@ -23,14 +23,17 @@ namespace osu.Game.Overlays.AccountCreation
private OsuTextFlowContainer multiAccountExplanationText;
private LinkFlowContainer furtherAssistance;
- [Resolved(CanBeNull = true)]
+ [Resolved(canBeNull: true)]
private IAPIProvider api { get; set; }
+ [Resolved(canBeNull: true)]
+ private OsuGame game { get; set; }
+
private const string help_centre_url = "/help/wiki/Help_Centre#login";
public override void OnEntering(IScreen last)
{
- if (string.IsNullOrEmpty(api?.ProvidedUsername))
+ if (string.IsNullOrEmpty(api?.ProvidedUsername) || game?.UseDevelopmentServer == true)
{
this.FadeOut();
this.Push(new ScreenEntry());
@@ -41,7 +44,7 @@ namespace osu.Game.Overlays.AccountCreation
}
[BackgroundDependencyLoader(true)]
- private void load(OsuColour colours, OsuGame game, TextureStore textures)
+ private void load(OsuColour colours, TextureStore textures)
{
if (string.IsNullOrEmpty(api?.ProvidedUsername))
return;
diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs
index 81315f9638..443b3dcf01 100644
--- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs
+++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs
@@ -26,7 +26,10 @@ namespace osu.Game.Overlays
AccentColour = colourProvider.Light2;
}
- protected override TabItem CreateTabItem(string value) => new ControlTabItem(value);
+ protected override TabItem CreateTabItem(string value) => new ControlTabItem(value)
+ {
+ AccentColour = AccentColour,
+ };
private class ControlTabItem : BreadcrumbTabItem
{
diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs
index 336430fd9b..3051ca7dbe 100644
--- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs
+++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs
@@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Dashboard
private FillFlowContainer userFlow;
[Resolved]
- private SpectatorStreamingClient spectatorStreaming { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[BackgroundDependencyLoader]
private void load()
@@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Dashboard
{
base.LoadComplete();
- playingUsers.BindTo(spectatorStreaming.PlayingUsers);
+ playingUsers.BindTo(spectatorClient.PlayingUsers);
playingUsers.BindCollectionChanged(onUsersChanged, true);
}
diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs
index 754b260bf0..e31e307d4d 100644
--- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs
+++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs
@@ -96,7 +96,8 @@ namespace osu.Game.Overlays.Mods
Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774");
Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e");
- RelativeSizeAxes = Axes.Both;
+ RelativeSizeAxes = Axes.X;
+ Height = HEIGHT;
Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING };
diff --git a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs b/osu.Game/Overlays/News/Displays/ArticleListing.cs
similarity index 71%
rename from osu.Game/Overlays/News/Displays/FrontPageDisplay.cs
rename to osu.Game/Overlays/News/Displays/ArticleListing.cs
index a1bc6c650b..b49326a1f1 100644
--- a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs
+++ b/osu.Game/Overlays/News/Displays/ArticleListing.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
@@ -9,12 +10,18 @@ using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
+using osu.Game.Online.API.Requests.Responses;
using osuTK;
namespace osu.Game.Overlays.News.Displays
{
- public class FrontPageDisplay : CompositeDrawable
+ ///
+ /// Lists articles in a vertical flow for a specified year.
+ ///
+ public class ArticleListing : CompositeDrawable
{
+ public Action SidebarMetadataUpdated;
+
[Resolved]
private IAPIProvider api { get; set; }
@@ -24,6 +31,17 @@ namespace osu.Game.Overlays.News.Displays
private GetNewsRequest request;
private Cursor lastCursor;
+ private readonly int? year;
+
+ ///
+ /// Instantiate a listing for the specified year.
+ ///
+ /// The year to load articles from. If null, will show the most recent articles.
+ public ArticleListing(int? year = null)
+ {
+ this.year = year;
+ }
+
[BackgroundDependencyLoader]
private void load()
{
@@ -74,7 +92,7 @@ namespace osu.Game.Overlays.News.Displays
{
request?.Cancel();
- request = new GetNewsRequest(lastCursor);
+ request = new GetNewsRequest(year, lastCursor);
request.Success += response => Schedule(() => onSuccess(response));
api.PerformAsync(request);
}
@@ -85,22 +103,19 @@ namespace osu.Game.Overlays.News.Displays
{
cancellationToken?.Cancel();
+ // only needs to be updated on the initial load, as the content won't change during pagination.
+ if (lastCursor == null)
+ SidebarMetadataUpdated?.Invoke(response.SidebarMetadata);
+
+ // store cursor for next pagination request.
lastCursor = response.Cursor;
- var flow = new FillFlowContainer
+ LoadComponentsAsync(response.NewsPosts.Select(p => new NewsCard(p)).ToList(), loaded =>
{
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Direction = FillDirection.Vertical,
- Spacing = new Vector2(0, 10),
- Children = response.NewsPosts.Select(p => new NewsCard(p)).ToList()
- };
+ content.AddRange(loaded);
- LoadComponentAsync(flow, loaded =>
- {
- content.Add(loaded);
showMore.IsLoading = false;
- showMore.Alpha = lastCursor == null ? 0 : 1;
+ showMore.Alpha = response.Cursor != null ? 1 : 0;
}, (cancellationToken = new CancellationTokenSource()).Token);
}
diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs
index 63174128e7..94bfd62c32 100644
--- a/osu.Game/Overlays/News/NewsHeader.cs
+++ b/osu.Game/Overlays/News/NewsHeader.cs
@@ -19,13 +19,18 @@ namespace osu.Game.Overlays.News
{
TabControl.AddItem(front_page_string);
+ article.BindValueChanged(onArticleChanged, true);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
Current.BindValueChanged(e =>
{
if (e.NewValue == front_page_string)
ShowFrontPage?.Invoke();
});
-
- article.BindValueChanged(onArticleChanged, true);
}
public void SetFrontPage() => article.Value = null;
diff --git a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs
index d14ad90ef4..9e397e78c8 100644
--- a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs
+++ b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs
@@ -9,6 +9,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Framework.Graphics.Shapes;
using osuTK;
using System.Linq;
+using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.News.Sidebar
{
@@ -31,30 +32,55 @@ namespace osu.Game.Overlays.News.Sidebar
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4
},
+ new Box
+ {
+ RelativeSizeAxes = Axes.Y,
+ Width = OsuScrollContainer.SCROLL_BAR_HEIGHT,
+ Anchor = Anchor.TopRight,
+ Origin = Anchor.TopRight,
+ Colour = colourProvider.Background3,
+ Alpha = 0.5f
+ },
new Container
{
RelativeSizeAxes = Axes.Both,
- Padding = new MarginPadding
+ Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin
+ Child = new OsuScrollContainer
{
- Vertical = 20,
- Left = 50,
- Right = 30
- },
- Child = new FillFlowContainer
- {
- Direction = FillDirection.Vertical,
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Spacing = new Vector2(0, 20),
- Children = new Drawable[]
+ RelativeSizeAxes = Axes.Both,
+ Child = new Container
{
- new YearsPanel(),
- monthsFlow = new FillFlowContainer
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Padding = new MarginPadding { Right = 3 }, // Addeded 3px back
+ Child = new Container
{
- AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
- Direction = FillDirection.Vertical,
- Spacing = new Vector2(0, 10)
+ AutoSizeAxes = Axes.Y,
+ Padding = new MarginPadding
+ {
+ Vertical = 20,
+ Left = 50,
+ Right = 30
+ },
+ Child = new FillFlowContainer
+ {
+ Direction = FillDirection.Vertical,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Spacing = new Vector2(0, 20),
+ Children = new Drawable[]
+ {
+ new YearsPanel(),
+ monthsFlow = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.Y,
+ RelativeSizeAxes = Axes.X,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(0, 10)
+ }
+ }
+ }
}
}
}
diff --git a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs
index b6bbdbb6d4..b07c9924b9 100644
--- a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs
+++ b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs
@@ -81,6 +81,9 @@ namespace osu.Game.Overlays.News.Sidebar
{
public int Year { get; }
+ [Resolved(canBeNull: true)]
+ private NewsOverlay overlay { get; set; }
+
private readonly bool isCurrent;
public YearButton(int year, bool isCurrent)
@@ -106,7 +109,11 @@ namespace osu.Game.Overlays.News.Sidebar
{
IdleColour = isCurrent ? Color4.White : colourProvider.Light2;
HoverColour = isCurrent ? Color4.White : colourProvider.Light1;
- Action = () => { }; // Avoid button being disabled since there's no proper action assigned.
+ Action = () =>
+ {
+ if (!isCurrent)
+ overlay?.ShowYear(Year);
+ };
}
}
}
diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs
index 5beb285216..dd6de40ecb 100644
--- a/osu.Game/Overlays/NewsOverlay.cs
+++ b/osu.Game/Overlays/NewsOverlay.cs
@@ -1,11 +1,14 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Threading;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
using osu.Game.Overlays.News;
using osu.Game.Overlays.News.Displays;
+using osu.Game.Overlays.News.Sidebar;
namespace osu.Game.Overlays
{
@@ -13,9 +16,48 @@ namespace osu.Game.Overlays
{
private readonly Bindable article = new Bindable(null);
+ private readonly Container sidebarContainer;
+ private readonly NewsSidebar sidebar;
+
+ private readonly Container content;
+
+ private CancellationTokenSource cancellationToken;
+
+ private bool displayUpdateRequired = true;
+
public NewsOverlay()
: base(OverlayColourScheme.Purple, false)
{
+ Child = new GridContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ RowDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize)
+ },
+ ColumnDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize),
+ new Dimension()
+ },
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ sidebarContainer = new Container
+ {
+ AutoSizeAxes = Axes.X,
+ Child = sidebar = new NewsSidebar()
+ },
+ content = new Container
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y
+ }
+ }
+ }
+ };
}
protected override void LoadComplete()
@@ -26,12 +68,7 @@ namespace osu.Game.Overlays
article.BindValueChanged(onArticleChanged);
}
- protected override NewsHeader CreateHeader() => new NewsHeader
- {
- ShowFrontPage = ShowFrontPage
- };
-
- private bool displayUpdateRequired = true;
+ protected override NewsHeader CreateHeader() => new NewsHeader { ShowFrontPage = ShowFrontPage };
protected override void PopIn()
{
@@ -56,38 +93,69 @@ namespace osu.Game.Overlays
Show();
}
+ public void ShowYear(int year)
+ {
+ loadFrontPage(year);
+ Show();
+ }
+
public void ShowArticle(string slug)
{
article.Value = slug;
Show();
}
- private CancellationTokenSource cancellationToken;
-
- private void onArticleChanged(ValueChangedEvent e)
- {
- cancellationToken?.Cancel();
- Loading.Show();
-
- if (e.NewValue == null)
- {
- Header.SetFrontPage();
- LoadDisplay(new FrontPageDisplay());
- return;
- }
-
- Header.SetArticle(e.NewValue);
- LoadDisplay(Empty());
- }
-
protected void LoadDisplay(Drawable display)
{
ScrollFlow.ScrollToStart();
- LoadComponentAsync(display, loaded =>
+ LoadComponentAsync(display, loaded => content.Child = loaded, (cancellationToken = new CancellationTokenSource()).Token);
+ }
+
+ protected override void UpdateAfterChildren()
+ {
+ base.UpdateAfterChildren();
+ sidebarContainer.Height = DrawHeight;
+ sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0));
+ }
+
+ private void onArticleChanged(ValueChangedEvent article)
+ {
+ if (article.NewValue == null)
+ loadFrontPage();
+ else
+ loadArticle(article.NewValue);
+ }
+
+ private void loadFrontPage(int? year = null)
+ {
+ beginLoading();
+
+ Header.SetFrontPage();
+
+ var page = new ArticleListing(year);
+ page.SidebarMetadataUpdated += metadata => Schedule(() =>
{
- Child = loaded;
+ sidebar.Metadata.Value = metadata;
Loading.Hide();
- }, (cancellationToken = new CancellationTokenSource()).Token);
+ });
+ LoadDisplay(page);
+ }
+
+ private void loadArticle(string article)
+ {
+ beginLoading();
+
+ Header.SetArticle(article);
+
+ // Temporary, should be handled by ArticleDisplay later
+ LoadDisplay(Empty());
+ Loading.Hide();
+ }
+
+ private void beginLoading()
+ {
+ cancellationToken?.Cancel();
+ Loading.Show();
}
protected override void Dispose(bool isDisposing)
diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs
index be464fa2b7..0b5ec4f338 100644
--- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs
@@ -73,11 +73,6 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
LabelText = "Always play first combo break sound",
Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak)
},
- new SettingsEnumDropdown
- {
- LabelText = "Score meter type",
- Current = config.GetBindable(OsuSetting.ScoreMeter)
- },
new SettingsEnumDropdown
{
LabelText = "Score display mode",
diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs
index e7c69e89fe..349a112477 100644
--- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs
@@ -11,9 +11,9 @@ using osuTK;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
-using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Framework.Screens;
+using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Settings.Sections.Maintenance
{
@@ -69,20 +69,24 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
RelativeSizeAxes = Axes.Both,
RowDimensions = new[]
{
+ new Dimension(GridSizeMode.AutoSize),
new Dimension(),
- new Dimension(GridSizeMode.Relative, 0.8f),
- new Dimension(),
+ new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new Drawable[]
{
- new OsuSpriteText
+ new OsuTextFlowContainer(cp =>
{
- Text = HeaderText,
- Font = OsuFont.Default.With(size: 40),
- Origin = Anchor.Centre,
- Anchor = Anchor.Centre,
+ cp.Font = OsuFont.Default.With(size: 24);
+ })
+ {
+ Text = HeaderText.ToString(),
+ TextAnchor = Anchor.TopCentre,
+ Margin = new MarginPadding(10),
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
}
},
new Drawable[]
@@ -99,6 +103,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 300,
+ Margin = new MarginPadding(10),
Text = "Select directory",
Action = () => OnSelection(directorySelector.CurrentPath.Value)
},
diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs
index 848ce381a9..a38ca81e23 100644
--- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Collections;
+using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Scoring;
using osu.Game.Skinning;
@@ -29,9 +30,9 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
private TriangleButton undeleteButton;
[BackgroundDependencyLoader(permitNulls: true)]
- private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay)
+ private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay)
{
- if (beatmaps.SupportsImportFromStable)
+ if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importBeatmapsButton = new SettingsButton
{
@@ -39,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importBeatmapsButton.Enabled.Value = false;
- beatmaps.ImportFromStableAsync().ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true));
+ stableImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true));
}
});
}
@@ -57,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
}
});
- if (scores.SupportsImportFromStable)
+ if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importScoresButton = new SettingsButton
{
@@ -65,7 +66,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importScoresButton.Enabled.Value = false;
- scores.ImportFromStableAsync().ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true));
+ stableImportManager.ImportFromStableAsync(StableContent.Scores).ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true));
}
});
}
@@ -83,7 +84,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
}
});
- if (skins.SupportsImportFromStable)
+ if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importSkinsButton = new SettingsButton
{
@@ -91,7 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importSkinsButton.Enabled.Value = false;
- skins.ImportFromStableAsync().ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true));
+ stableImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true));
}
});
}
@@ -111,7 +112,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
if (collectionManager != null)
{
- if (collectionManager.SupportsImportFromStable)
+ if (stableImportManager?.SupportsImportFromStable == true)
{
Add(importCollectionsButton = new SettingsButton
{
@@ -119,7 +120,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Action = () =>
{
importCollectionsButton.Enabled.Value = false;
- collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true));
+ stableImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true));
}
});
}
diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs
new file mode 100644
index 0000000000..904c9deaae
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs
@@ -0,0 +1,38 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Threading.Tasks;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Screens;
+using osu.Game.Overlays.Dialog;
+
+namespace osu.Game.Overlays.Settings.Sections.Maintenance
+{
+ public class StableDirectoryLocationDialog : PopupDialog
+ {
+ [Resolved]
+ private OsuGame game { get; set; }
+
+ public StableDirectoryLocationDialog(TaskCompletionSource taskCompletionSource)
+ {
+ HeaderText = "Failed to automatically locate an osu!stable installation.";
+ BodyText = "An existing install could not be located. If you know where it is, you can help locate it.";
+ Icon = FontAwesome.Solid.QuestionCircle;
+
+ Buttons = new PopupDialogButton[]
+ {
+ new PopupDialogOkButton
+ {
+ Text = "Sure! I know where it is located!",
+ Action = () => Schedule(() => game.PerformFromScreen(screen => screen.Push(new StableDirectorySelectScreen(taskCompletionSource))))
+ },
+ new PopupDialogCancelButton
+ {
+ Text = "Actually I don't have osu!stable installed.",
+ Action = () => taskCompletionSource.TrySetCanceled()
+ }
+ };
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs
new file mode 100644
index 0000000000..4aea05fb14
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs
@@ -0,0 +1,39 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using osu.Framework.Localisation;
+using osu.Framework.Screens;
+
+namespace osu.Game.Overlays.Settings.Sections.Maintenance
+{
+ public class StableDirectorySelectScreen : DirectorySelectScreen
+ {
+ private readonly TaskCompletionSource taskCompletionSource;
+
+ protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled;
+
+ protected override bool IsValidDirectory(DirectoryInfo info) => info?.GetFiles("osu!.*.cfg").Any() ?? false;
+
+ public override LocalisableString HeaderText => "Please select your osu!stable install location";
+
+ public StableDirectorySelectScreen(TaskCompletionSource taskCompletionSource)
+ {
+ this.taskCompletionSource = taskCompletionSource;
+ }
+
+ protected override void OnSelection(DirectoryInfo directory)
+ {
+ taskCompletionSource.TrySetResult(directory.FullName);
+ this.Exit();
+ }
+
+ public override bool OnExiting(IScreen next)
+ {
+ taskCompletionSource.TrySetCanceled();
+ return base.OnExiting(next);
+ }
+ }
+}
diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
index 4ad8c815fe..82e90399c9 100644
--- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
+++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs
@@ -36,7 +36,8 @@ namespace osu.Game.Rulesets.Edit
[Resolved(canBeNull: true)]
protected EditorClock EditorClock { get; private set; }
- private readonly IBindable beatmap = new Bindable();
+ [Resolved]
+ private EditorBeatmap beatmap { get; set; }
private Bindable startTimeBindable;
@@ -58,10 +59,8 @@ namespace osu.Game.Rulesets.Edit
}
[BackgroundDependencyLoader]
- private void load(IBindable beatmap)
+ private void load()
{
- this.beatmap.BindTo(beatmap);
-
startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy();
startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true);
}
@@ -113,7 +112,7 @@ namespace osu.Game.Rulesets.Edit
/// Invokes ,
/// refreshing and parameters for the .
///
- protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty);
+ protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false;
diff --git a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs
index ed0430012a..64e1ac16bd 100644
--- a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs
+++ b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs
@@ -3,7 +3,6 @@
#nullable enable
-using System;
using System.Diagnostics;
using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Pooling;
@@ -27,13 +26,14 @@ namespace osu.Game.Rulesets.Objects.Pooling
///
protected bool HasEntryApplied { get; private set; }
+ // Drawable's lifetime gets out of sync with entry's lifetime if entry's lifetime is modified.
+ // We cannot delegate getter to `Entry.LifetimeStart` because it is incompatible with `LifetimeManagementContainer` due to how lifetime change is detected.
public override double LifetimeStart
{
- get => Entry?.LifetimeStart ?? double.MinValue;
+ get => base.LifetimeStart;
set
{
- if (Entry == null && LifetimeStart != value)
- throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime)} when entry is not set");
+ base.LifetimeStart = value;
if (Entry != null)
Entry.LifetimeStart = value;
@@ -42,11 +42,10 @@ namespace osu.Game.Rulesets.Objects.Pooling
public override double LifetimeEnd
{
- get => Entry?.LifetimeEnd ?? double.MaxValue;
+ get => base.LifetimeEnd;
set
{
- if (Entry == null && LifetimeEnd != value)
- throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime)} when entry is not set");
+ base.LifetimeEnd = value;
if (Entry != null)
Entry.LifetimeEnd = value;
@@ -80,7 +79,12 @@ namespace osu.Game.Rulesets.Objects.Pooling
free();
Entry = entry;
+
+ base.LifetimeStart = entry.LifetimeStart;
+ base.LifetimeEnd = entry.LifetimeEnd;
+
OnApply(entry);
+
HasEntryApplied = true;
}
@@ -112,7 +116,11 @@ namespace osu.Game.Rulesets.Objects.Pooling
Debug.Assert(Entry != null && HasEntryApplied);
OnFree(Entry);
+
Entry = null;
+ base.LifetimeStart = double.MinValue;
+ base.LifetimeEnd = double.MaxValue;
+
HasEntryApplied = false;
}
}
diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs
index 643ded4cad..d18e0f9541 100644
--- a/osu.Game/Rulesets/UI/ReplayRecorder.cs
+++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.UI
public int RecordFrameRate = 60;
[Resolved(canBeNull: true)]
- private SpectatorStreamingClient spectatorStreaming { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[Resolved]
private GameplayBeatmap gameplayBeatmap { get; set; }
@@ -49,13 +49,13 @@ namespace osu.Game.Rulesets.UI
inputManager = GetContainingInputManager();
- spectatorStreaming?.BeginPlaying(gameplayBeatmap, target);
+ spectatorClient?.BeginPlaying(gameplayBeatmap, target);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
- spectatorStreaming?.EndPlaying();
+ spectatorClient?.EndPlaying();
}
protected override void Update()
@@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.UI
{
target.Replay.Frames.Add(frame);
- spectatorStreaming?.HandleFrame(frame);
+ spectatorClient?.HandleFrame(frame);
}
}
diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs
index 6ab4ca8267..2141c490df 100644
--- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs
@@ -168,13 +168,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
if (SelectedBlueprints.All(b => b.Item is IHasComboInformation))
{
- yield return new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } };
+ yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } };
}
yield return new OsuMenuItem("Sound")
{
Items = SelectionSampleStates.Select(kvp =>
- new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
+ new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray()
};
}
diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
index 375aac729d..a53e253581 100644
--- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
@@ -72,8 +72,8 @@ namespace osu.Game.Screens.OnlinePlay.Match
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Depth = float.MinValue,
- RelativeSizeAxes = Axes.Both,
- Height = 0.5f,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING },
Child = userModsSelectOverlay = new UserModSelectOverlay
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs
index a13d2cf540..cc51b5b691 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private IBindable operationInProgress;
[Resolved]
- private StatefulMultiplayerClient multiplayerClient { get; set; }
+ private MultiplayerClient multiplayerClient { get; set; }
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs
index 3199232f6f..fe9979b161 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private IRoomManager manager { get; set; }
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
[Resolved]
private Bindable currentRoom { get; set; }
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs
index 085c824bdc..a065d04f64 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs
@@ -15,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public class Multiplayer : OnlinePlayScreen
{
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
public override void OnResuming(IScreen last)
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs
index 0a9a3f680f..4d20652465 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs
@@ -18,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room);
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
public override void Open(Room room)
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs
index c9f0f6de90..3733b85a5e 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs
@@ -17,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public class MultiplayerMatchSongSelect : OnlinePlaySongSelect
{
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
private LoadingLayer loadingLayer;
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
index 783b8b4bf2..62ef70ed68 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
@@ -43,7 +43,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public override string ShortTitle => "room";
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs
index ae2042fbe8..1bbe49a705 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs
@@ -26,7 +26,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
protected override bool CheckModsAllowFailure() => false;
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
private IBindable isConnected;
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
index 8030107ad8..d334c618f5 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs
@@ -13,7 +13,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
protected MultiplayerRoom Room => Client.Room;
[Resolved]
- protected StatefulMultiplayerClient Client { get; private set; }
+ protected MultiplayerClient Client { get; private set; }
protected override void LoadComplete()
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs
index 1e57847f04..8526196902 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public class MultiplayerRoomManager : RoomManager
{
[Resolved]
- private StatefulMultiplayerClient multiplayerClient { get; set; }
+ private MultiplayerClient multiplayerClient { get; set; }
public readonly Bindable TimeBetweenListingPolls = new Bindable();
public readonly Bindable TimeBetweenSelectionPolls = new Bindable();
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs
index 6c1a55a0eb..7e442c6568 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs
@@ -10,7 +10,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
public class ParticipantsListHeader : OverlinedHeader
{
[Resolved]
- private StatefulMultiplayerClient client { get; set; }
+ private MultiplayerClient client { get; set; }
public ParticipantsListHeader()
: base("Participants")
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
index 8c7b7bab01..277aa5d772 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
@@ -28,10 +28,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true);
[Resolved]
- private SpectatorStreamingClient spectatorClient { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[Resolved]
- private StatefulMultiplayerClient multiplayerClient { get; set; }
+ private MultiplayerClient multiplayerClient { get; set; }
private readonly PlayerArea[] instances;
private MasterGameplayClockContainer masterClockContainer;
diff --git a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs
deleted file mode 100644
index a24d9c10cb..0000000000
--- a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Extensions.IEnumerableExtensions;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Game.Configuration;
-using osu.Game.Rulesets.Scoring;
-using osu.Game.Screens.Play.HUD.HitErrorMeters;
-
-namespace osu.Game.Screens.Play.HUD
-{
- public class HitErrorDisplay : Container
- {
- private const int fade_duration = 200;
- private const int margin = 10;
-
- private readonly Bindable type = new Bindable();
-
- private readonly HitWindows hitWindows;
-
- public HitErrorDisplay(HitWindows hitWindows)
- {
- this.hitWindows = hitWindows;
-
- RelativeSizeAxes = Axes.Both;
- }
-
- [BackgroundDependencyLoader]
- private void load(OsuConfigManager config)
- {
- config.BindWith(OsuSetting.ScoreMeter, type);
- }
-
- protected override void LoadComplete()
- {
- base.LoadComplete();
- type.BindValueChanged(typeChanged, true);
- }
-
- private void typeChanged(ValueChangedEvent type)
- {
- Children.ForEach(c => c.FadeOut(fade_duration, Easing.OutQuint));
-
- if (hitWindows == null)
- return;
-
- switch (type.NewValue)
- {
- case ScoreMeterType.HitErrorBoth:
- createBar(Anchor.CentreLeft);
- createBar(Anchor.CentreRight);
- break;
-
- case ScoreMeterType.HitErrorLeft:
- createBar(Anchor.CentreLeft);
- break;
-
- case ScoreMeterType.HitErrorRight:
- createBar(Anchor.CentreRight);
- break;
-
- case ScoreMeterType.HitErrorBottom:
- createBar(Anchor.BottomCentre);
- break;
-
- case ScoreMeterType.ColourBoth:
- createColour(Anchor.CentreLeft);
- createColour(Anchor.CentreRight);
- break;
-
- case ScoreMeterType.ColourLeft:
- createColour(Anchor.CentreLeft);
- break;
-
- case ScoreMeterType.ColourRight:
- createColour(Anchor.CentreRight);
- break;
-
- case ScoreMeterType.ColourBottom:
- createColour(Anchor.BottomCentre);
- break;
- }
- }
-
- private void createBar(Anchor anchor)
- {
- bool rightAligned = (anchor & Anchor.x2) > 0;
- bool bottomAligned = (anchor & Anchor.y2) > 0;
-
- var display = new BarHitErrorMeter(hitWindows, rightAligned)
- {
- Margin = new MarginPadding(margin),
- Anchor = anchor,
- Origin = bottomAligned ? Anchor.CentreLeft : anchor,
- Alpha = 0,
- Rotation = bottomAligned ? 270 : 0
- };
-
- completeDisplayLoading(display);
- }
-
- private void createColour(Anchor anchor)
- {
- bool bottomAligned = (anchor & Anchor.y2) > 0;
-
- var display = new ColourHitErrorMeter(hitWindows)
- {
- Margin = new MarginPadding(margin),
- Anchor = anchor,
- Origin = bottomAligned ? Anchor.CentreLeft : anchor,
- Alpha = 0,
- Rotation = bottomAligned ? 270 : 0
- };
-
- completeDisplayLoading(display);
- }
-
- private void completeDisplayLoading(HitErrorMeter display)
- {
- Add(display);
- display.FadeInFromZero(fade_duration, Easing.OutQuint);
- }
- }
-}
diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
index 0e147f9238..5d0263772d 100644
--- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
+++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs
@@ -20,8 +20,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
{
public class BarHitErrorMeter : HitErrorMeter
{
- private readonly Anchor alignment;
-
private const int arrow_move_duration = 400;
private const int judgement_line_width = 6;
@@ -43,11 +41,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
private double maxHitWindow;
- public BarHitErrorMeter(HitWindows hitWindows, bool rightAligned = false)
- : base(hitWindows)
+ public BarHitErrorMeter()
{
- alignment = rightAligned ? Anchor.x0 : Anchor.x2;
-
AutoSizeAxes = Axes.Both;
}
@@ -63,33 +58,42 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
Margin = new MarginPadding(2),
Children = new Drawable[]
{
- judgementsContainer = new Container
+ new Container
{
- Anchor = Anchor.y1 | alignment,
- Origin = Anchor.y1 | alignment,
- Width = judgement_line_width,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Width = chevron_size,
RelativeSizeAxes = Axes.Y,
+ Child = arrow = new SpriteIcon
+ {
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.Centre,
+ RelativePositionAxes = Axes.Y,
+ Y = 0.5f,
+ Icon = FontAwesome.Solid.ChevronRight,
+ Size = new Vector2(chevron_size),
+ }
},
colourBars = new Container
{
Width = bar_width,
RelativeSizeAxes = Axes.Y,
- Anchor = Anchor.y1 | alignment,
- Origin = Anchor.y1 | alignment,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
Children = new Drawable[]
{
colourBarsEarly = new Container
{
- Anchor = Anchor.y1 | alignment,
- Origin = alignment,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
Scale = new Vector2(1, -1),
},
colourBarsLate = new Container
{
- Anchor = Anchor.y1 | alignment,
- Origin = alignment,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
},
@@ -115,21 +119,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
}
}
},
- new Container
+ judgementsContainer = new Container
{
- Anchor = Anchor.y1 | alignment,
- Origin = Anchor.y1 | alignment,
- Width = chevron_size,
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Width = judgement_line_width,
RelativeSizeAxes = Axes.Y,
- Child = arrow = new SpriteIcon
- {
- Anchor = Anchor.TopCentre,
- Origin = Anchor.Centre,
- RelativePositionAxes = Axes.Y,
- Y = 0.5f,
- Icon = alignment == Anchor.x2 ? FontAwesome.Solid.ChevronRight : FontAwesome.Solid.ChevronLeft,
- Size = new Vector2(chevron_size),
- }
},
}
};
@@ -152,19 +147,22 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
{
var windows = HitWindows.GetAllAvailableWindows().ToArray();
- maxHitWindow = windows.First().length;
+ // max to avoid div-by-zero.
+ maxHitWindow = Math.Max(1, windows.First().length);
for (var i = 0; i < windows.Length; i++)
{
var (result, length) = windows[i];
- colourBarsEarly.Add(createColourBar(result, (float)(length / maxHitWindow), i == 0));
- colourBarsLate.Add(createColourBar(result, (float)(length / maxHitWindow), i == 0));
+ var hitWindow = (float)(length / maxHitWindow);
+
+ colourBarsEarly.Add(createColourBar(result, hitWindow, i == 0));
+ colourBarsLate.Add(createColourBar(result, hitWindow, i == 0));
}
// a little nub to mark the centre point.
var centre = createColourBar(windows.Last().result, 0.01f);
- centre.Anchor = centre.Origin = Anchor.y1 | (alignment == Anchor.x2 ? Anchor.x0 : Anchor.x2);
+ centre.Anchor = centre.Origin = Anchor.CentreLeft;
centre.Width = 2.5f;
colourBars.Add(centre);
@@ -236,8 +234,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
judgementsContainer.Add(new JudgementLine
{
Y = getRelativeJudgementPosition(judgement.TimeOffset),
- Anchor = alignment == Anchor.x2 ? Anchor.x0 : Anchor.x2,
- Origin = Anchor.y1 | (alignment == Anchor.x2 ? Anchor.x0 : Anchor.x2),
+ Origin = Anchor.CentreLeft,
});
arrow.MoveToY(
diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs
index 465439cf19..0eb2367f73 100644
--- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs
+++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs
@@ -7,7 +7,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Judgements;
-using osu.Game.Rulesets.Scoring;
using osuTK;
using osuTK.Graphics;
@@ -19,8 +18,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
private readonly JudgementFlow judgementsFlow;
- public ColourHitErrorMeter(HitWindows hitWindows)
- : base(hitWindows)
+ public ColourHitErrorMeter()
{
AutoSizeAxes = Axes.Both;
InternalChild = judgementsFlow = new JudgementFlow();
diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs
index 37e9ea43c5..b0f9928b13 100644
--- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs
+++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs
@@ -6,13 +6,15 @@ using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.UI;
+using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD.HitErrorMeters
{
- public abstract class HitErrorMeter : CompositeDrawable
+ public abstract class HitErrorMeter : CompositeDrawable, ISkinnableDrawable
{
- protected readonly HitWindows HitWindows;
+ protected HitWindows HitWindows { get; private set; }
[Resolved]
private ScoreProcessor processor { get; set; }
@@ -20,9 +22,10 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
[Resolved]
private OsuColour colours { get; set; }
- protected HitErrorMeter(HitWindows hitWindows)
+ [BackgroundDependencyLoader(true)]
+ private void load(DrawableRuleset drawableRuleset)
{
- HitWindows = hitWindows;
+ HitWindows = drawableRuleset?.FirstAvailableHitWindows ?? HitWindows.Empty;
}
protected override void LoadComplete()
diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs
index 70de067784..c3bfe19b29 100644
--- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs
+++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs
@@ -22,10 +22,10 @@ namespace osu.Game.Screens.Play.HUD
protected readonly Dictionary UserScores = new Dictionary();
[Resolved]
- private SpectatorStreamingClient streamingClient { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[Resolved]
- private StatefulMultiplayerClient multiplayerClient { get; set; }
+ private MultiplayerClient multiplayerClient { get; set; }
[Resolved]
private UserLookupCache userLookupCache { get; set; }
@@ -55,8 +55,6 @@ namespace osu.Game.Screens.Play.HUD
foreach (var userId in playingUsers)
{
- streamingClient.WatchUser(userId);
-
// probably won't be required in the final implementation.
var resolvedUser = userLookupCache.GetUserAsync(userId).Result;
@@ -80,6 +78,8 @@ namespace osu.Game.Screens.Play.HUD
// BindableList handles binding in a really bad way (Clear then AddRange) so we need to do this manually..
foreach (int userId in playingUsers)
{
+ spectatorClient.WatchUser(userId);
+
if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(userId))
usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { userId }));
}
@@ -88,7 +88,7 @@ namespace osu.Game.Screens.Play.HUD
playingUsers.BindCollectionChanged(usersChanged);
// this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer).
- streamingClient.OnNewFrames += handleIncomingFrames;
+ spectatorClient.OnNewFrames += handleIncomingFrames;
}
private void usersChanged(object sender, NotifyCollectionChangedEventArgs e)
@@ -98,7 +98,7 @@ namespace osu.Game.Screens.Play.HUD
case NotifyCollectionChangedAction.Remove:
foreach (var userId in e.OldItems.OfType())
{
- streamingClient.StopWatchingUser(userId);
+ spectatorClient.StopWatchingUser(userId);
if (UserScores.TryGetValue(userId, out var trackedData))
trackedData.MarkUserQuit();
@@ -123,14 +123,14 @@ namespace osu.Game.Screens.Play.HUD
{
base.Dispose(isDisposing);
- if (streamingClient != null)
+ if (spectatorClient != null)
{
foreach (var user in playingUsers)
{
- streamingClient.StopWatchingUser(user);
+ spectatorClient.StopWatchingUser(user);
}
- streamingClient.OnNewFrames -= handleIncomingFrames;
+ spectatorClient.OnNewFrames -= handleIncomingFrames;
}
}
diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs
index dcee64ff0d..ab5b01cab6 100644
--- a/osu.Game/Screens/Play/HUDOverlay.cs
+++ b/osu.Game/Screens/Play/HUDOverlay.cs
@@ -87,22 +87,10 @@ namespace osu.Game.Screens.Play
visibilityContainer = new Container
{
RelativeSizeAxes = Axes.Both,
- Children = new Drawable[]
+ Child = mainComponents = new SkinnableTargetContainer(SkinnableTarget.MainHUDComponents)
{
- 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),
- }
- },
- }
+ RelativeSizeAxes = Axes.Both,
+ },
},
topRightElements = new FillFlowContainer
{
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 6317a41bec..39f9e2d388 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -22,6 +22,7 @@ using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.IO.Archives;
using osu.Game.Online.API;
+using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Replays;
using osu.Game.Rulesets;
@@ -93,6 +94,9 @@ namespace osu.Game.Screens.Play
[Resolved]
private MusicController musicController { get; set; }
+ [Resolved]
+ private SpectatorClient spectatorClient { get; set; }
+
private Sample sampleRestart;
public BreakOverlay BreakOverlay;
@@ -882,6 +886,11 @@ namespace osu.Game.Screens.Play
return true;
}
+ // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous.
+ // To resolve test failures, forcefully end playing synchronously when this screen exits.
+ // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method.
+ spectatorClient.EndPlaying();
+
// GameplayClockContainer performs seeks / start / stop operations on the beatmap's track.
// as we are no longer the current screen, we cannot guarantee the track is still usable.
(GameplayClockContainer as MasterGameplayClockContainer)?.StopUsingBeatmapClock();
diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs
index b7939b5e75..cab44c7473 100644
--- a/osu.Game/Screens/Play/SongProgress.cs
+++ b/osu.Game/Screens/Play/SongProgress.cs
@@ -20,10 +20,14 @@ namespace osu.Game.Screens.Play
{
public class SongProgress : OverlayContainer, ISkinnableDrawable
{
- private const int info_height = 20;
- private const int bottom_bar_height = 5;
+ public const float MAX_HEIGHT = info_height + bottom_bar_height + graph_height + handle_height;
+
+ private const float info_height = 20;
+ private const float bottom_bar_height = 5;
private const float graph_height = SquareGraph.Column.WIDTH * 6;
- private static readonly Vector2 handle_size = new Vector2(10, 18);
+ private const float handle_height = 18;
+
+ private static readonly Vector2 handle_size = new Vector2(10, handle_height);
private const float transition_duration = 200;
diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs
index 9822f62dd8..a8125dfded 100644
--- a/osu.Game/Screens/Play/SpectatorPlayer.cs
+++ b/osu.Game/Screens/Play/SpectatorPlayer.cs
@@ -31,12 +31,12 @@ namespace osu.Game.Screens.Play
}
[Resolved]
- private SpectatorStreamingClient spectatorStreaming { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[BackgroundDependencyLoader]
private void load()
{
- spectatorStreaming.OnUserBeganPlaying += userBeganPlaying;
+ spectatorClient.OnUserBeganPlaying += userBeganPlaying;
AddInternal(new OsuSpriteText
{
@@ -66,7 +66,7 @@ namespace osu.Game.Screens.Play
public override bool OnExiting(IScreen next)
{
- spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
+ spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
return base.OnExiting(next);
}
@@ -84,8 +84,8 @@ namespace osu.Game.Screens.Play
{
base.Dispose(isDisposing);
- if (spectatorStreaming != null)
- spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
+ if (spectatorClient != null)
+ spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
}
}
}
diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs
index dabdf0a139..fd7af3af85 100644
--- a/osu.Game/Screens/Play/SpectatorResultsScreen.cs
+++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs
@@ -17,12 +17,12 @@ namespace osu.Game.Screens.Play
}
[Resolved]
- private SpectatorStreamingClient spectatorStreaming { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[BackgroundDependencyLoader]
private void load()
{
- spectatorStreaming.OnUserBeganPlaying += userBeganPlaying;
+ spectatorClient.OnUserBeganPlaying += userBeganPlaying;
}
private void userBeganPlaying(int userId, SpectatorState state)
@@ -40,8 +40,8 @@ namespace osu.Game.Screens.Play
{
base.Dispose(isDisposing);
- if (spectatorStreaming != null)
- spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
+ if (spectatorClient != null)
+ spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
}
}
}
diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs
index bca3a07fa6..c70b4dd35b 100644
--- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs
+++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs
@@ -10,11 +10,9 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
-using osu.Game.Audio;
using osu.Game.Graphics;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
-using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Screens.Ranking.Expanded.Accuracy
@@ -76,19 +74,14 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
private readonly ScoreInfo score;
- private readonly bool withFlair;
-
private SmoothCircularProgress accuracyCircle;
private SmoothCircularProgress innerMask;
private Container badges;
private RankText rankText;
- private SkinnableSound applauseSound;
-
- public AccuracyCircle(ScoreInfo score, bool withFlair)
+ public AccuracyCircle(ScoreInfo score)
{
this.score = score;
- this.withFlair = withFlair;
}
[BackgroundDependencyLoader]
@@ -211,13 +204,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
},
rankText = new RankText(score.Rank)
};
-
- if (withFlair)
- {
- AddInternal(applauseSound = score.Rank >= ScoreRank.A
- ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause"))
- : new SkinnableSound(new SampleInfo("Results/rankfail")));
- }
}
private ScoreRank getRank(ScoreRank rank)
@@ -256,7 +242,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
using (BeginDelayedSequence(TEXT_APPEAR_DELAY, true))
{
- this.Delay(-1440).Schedule(() => applauseSound?.Play());
rankText.Appear();
}
}
diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs
index 6a6b39b61c..4895240314 100644
--- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs
+++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs
@@ -122,7 +122,7 @@ namespace osu.Game.Screens.Ranking.Expanded
Margin = new MarginPadding { Top = 40 },
RelativeSizeAxes = Axes.X,
Height = 230,
- Child = new AccuracyCircle(score, withFlair)
+ Child = new AccuracyCircle(score)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs
index c1f5d92d17..a0ea27b640 100644
--- a/osu.Game/Screens/Ranking/ResultsScreen.cs
+++ b/osu.Game/Screens/Ranking/ResultsScreen.cs
@@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Bindings;
using osu.Framework.Screens;
+using osu.Game.Audio;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
@@ -19,13 +20,20 @@ using osu.Game.Online.API;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
+using osu.Game.Screens.Ranking.Expanded.Accuracy;
using osu.Game.Screens.Ranking.Statistics;
+using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Screens.Ranking
{
public abstract class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler
{
+ ///
+ /// Delay before the default applause sound should be played, in order to match the grade display timing in .
+ ///
+ public const double APPLAUSE_DELAY = AccuracyCircle.ACCURACY_TRANSFORM_DELAY + AccuracyCircle.TEXT_APPEAR_DELAY + ScorePanel.RESIZE_DURATION + ScorePanel.TOP_LAYER_EXPAND_DELAY - 1440;
+
protected const float BACKGROUND_BLUR = 20;
private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y;
@@ -56,6 +64,8 @@ namespace osu.Game.Screens.Ranking
private readonly bool allowRetry;
private readonly bool allowWatchingReplay;
+ private SkinnableSound applauseSound;
+
protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true)
{
Score = score;
@@ -146,6 +156,13 @@ namespace osu.Game.Screens.Ranking
bool shouldFlair = player != null && !Score.Mods.Any(m => m is ModAutoplay);
ScorePanelList.AddScore(Score, shouldFlair);
+
+ if (shouldFlair)
+ {
+ AddInternal(applauseSound = Score.Rank >= ScoreRank.A
+ ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause"))
+ : new SkinnableSound(new SampleInfo("Results/rankfail")));
+ }
}
if (allowWatchingReplay)
@@ -183,6 +200,9 @@ namespace osu.Game.Screens.Ranking
api.Queue(req);
statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true);
+
+ using (BeginDelayedSequence(APPLAUSE_DELAY))
+ Schedule(() => applauseSound?.Play());
}
protected override void Update()
diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs
index df710e4eb8..f66a998db6 100644
--- a/osu.Game/Screens/Ranking/ScorePanel.cs
+++ b/osu.Game/Screens/Ranking/ScorePanel.cs
@@ -54,12 +54,12 @@ namespace osu.Game.Screens.Ranking
///
/// Duration for the panel to resize into its expanded/contracted size.
///
- private const double resize_duration = 200;
+ public const double RESIZE_DURATION = 200;
///
- /// Delay after before the top layer is expanded.
+ /// Delay after before the top layer is expanded.
///
- private const double top_layer_expand_delay = 100;
+ public const double TOP_LAYER_EXPAND_DELAY = 100;
///
/// Duration for the top layer expansion.
@@ -208,8 +208,8 @@ namespace osu.Game.Screens.Ranking
case PanelState.Expanded:
Size = new Vector2(EXPANDED_WIDTH, expanded_height);
- topLayerBackground.FadeColour(expanded_top_layer_colour, resize_duration, Easing.OutQuint);
- middleLayerBackground.FadeColour(expanded_middle_layer_colour, resize_duration, Easing.OutQuint);
+ topLayerBackground.FadeColour(expanded_top_layer_colour, RESIZE_DURATION, Easing.OutQuint);
+ middleLayerBackground.FadeColour(expanded_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint);
topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0));
middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair).With(d => d.Alpha = 0));
@@ -221,20 +221,20 @@ namespace osu.Game.Screens.Ranking
case PanelState.Contracted:
Size = new Vector2(CONTRACTED_WIDTH, contracted_height);
- topLayerBackground.FadeColour(contracted_top_layer_colour, resize_duration, Easing.OutQuint);
- middleLayerBackground.FadeColour(contracted_middle_layer_colour, resize_duration, Easing.OutQuint);
+ topLayerBackground.FadeColour(contracted_top_layer_colour, RESIZE_DURATION, Easing.OutQuint);
+ middleLayerBackground.FadeColour(contracted_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint);
topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent(Score).With(d => d.Alpha = 0));
middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0));
break;
}
- content.ResizeTo(Size, resize_duration, Easing.OutQuint);
+ content.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint);
bool topLayerExpanded = topLayerContainer.Y < 0;
// If the top layer was already expanded, then we don't need to wait for the resize and can instead transform immediately. This looks better when changing the panel state.
- using (BeginDelayedSequence(topLayerExpanded ? 0 : resize_duration + top_layer_expand_delay, true))
+ using (BeginDelayedSequence(topLayerExpanded ? 0 : RESIZE_DURATION + TOP_LAYER_EXPAND_DELAY, true))
{
topLayerContainer.FadeIn();
diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
index d7e901b71e..a3fca3d4e1 100644
--- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
+++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs
@@ -250,7 +250,7 @@ namespace osu.Game.Screens.Select.Carousel
else
state = TernaryState.False;
- return new TernaryStateMenuItem(collection.Name.Value, MenuItemType.Standard, s =>
+ return new TernaryStateToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s =>
{
foreach (var b in beatmapSet.Beatmaps)
{
diff --git a/osu.Game/Screens/Select/ImportFromStablePopup.cs b/osu.Game/Screens/Select/ImportFromStablePopup.cs
index 8dab83b24c..d8137432bd 100644
--- a/osu.Game/Screens/Select/ImportFromStablePopup.cs
+++ b/osu.Game/Screens/Select/ImportFromStablePopup.cs
@@ -12,7 +12,7 @@ namespace osu.Game.Screens.Select
public ImportFromStablePopup(Action importFromStable)
{
HeaderText = @"You have no beatmaps!";
- BodyText = "An existing copy of osu! was found, though.\nWould you like to import your beatmaps, skins, collections and scores?\nThis will create a second copy of all files on disk.";
+ BodyText = "Would you like to import your beatmaps, skins, collections and scores from an existing osu!stable installation?\nThis will create a second copy of all files on disk.";
Icon = FontAwesome.Solid.Plane;
diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs
index 215700d87c..74e10037ab 100644
--- a/osu.Game/Screens/Select/SongSelect.cs
+++ b/osu.Game/Screens/Select/SongSelect.cs
@@ -22,7 +22,6 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Select.Options;
-using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
@@ -35,9 +34,9 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Game.Collections;
using osu.Game.Graphics.UserInterface;
-using osu.Game.Scoring;
using System.Diagnostics;
using osu.Game.Screens.Play;
+using osu.Game.Database;
namespace osu.Game.Screens.Select
{
@@ -52,6 +51,8 @@ namespace osu.Game.Screens.Select
protected virtual bool ShowFooter => true;
+ protected virtual bool DisplayStableImportPrompt => stableImportManager?.SupportsImportFromStable == true;
+
///
/// Can be null if is false.
///
@@ -84,6 +85,9 @@ namespace osu.Game.Screens.Select
[Resolved]
private BeatmapManager beatmaps { get; set; }
+ [Resolved(CanBeNull = true)]
+ private StableImportManager stableImportManager { get; set; }
+
protected ModSelectOverlay ModSelect { get; private set; }
protected Sample SampleConfirm { get; private set; }
@@ -101,7 +105,7 @@ namespace osu.Game.Screens.Select
private MusicController music { get; set; }
[BackgroundDependencyLoader(true)]
- private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender)
+ private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender)
{
// initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter).
transferRulesetValue();
@@ -282,18 +286,12 @@ namespace osu.Game.Screens.Select
{
Schedule(() =>
{
- // if we have no beatmaps but osu-stable is found, let's prompt the user to import.
- if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && beatmaps.StableInstallationAvailable)
+ // if we have no beatmaps, let's prompt the user to import from over a stable install if he has one.
+ if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && DisplayStableImportPrompt)
{
dialogOverlay.Push(new ImportFromStablePopup(() =>
{
- Task.Run(beatmaps.ImportFromStableAsync)
- .ContinueWith(_ =>
- {
- Task.Run(scores.ImportFromStableAsync);
- Task.Run(collections.ImportFromStableAsync);
- }, TaskContinuationOptions.OnlyOnRanToCompletion);
- Task.Run(skins.ImportFromStableAsync);
+ Task.Run(() => stableImportManager.ImportFromStableAsync(StableContent.All));
}));
}
});
diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs
index bcebd51954..9a20bb58b8 100644
--- a/osu.Game/Screens/Spectate/SpectatorScreen.cs
+++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.Spectator;
@@ -37,13 +38,12 @@ namespace osu.Game.Screens.Spectate
private RulesetStore rulesets { get; set; }
[Resolved]
- private SpectatorStreamingClient spectatorClient { get; set; }
+ private SpectatorClient spectatorClient { get; set; }
[Resolved]
private UserLookupCache userLookupCache { get; set; }
- // A lock is used to synchronise access to spectator/gameplay states, since this class is a screen which may become non-current and stop receiving updates at any point.
- private readonly object stateLock = new object();
+ private readonly IBindableDictionary playingUserStates = new BindableDictionary();
private readonly Dictionary userMap = new Dictionary();
private readonly Dictionary gameplayStates = new Dictionary();
@@ -63,36 +63,36 @@ namespace osu.Game.Screens.Spectate
{
base.LoadComplete();
- populateAllUsers().ContinueWith(_ => Schedule(() =>
+ getAllUsers().ContinueWith(users => Schedule(() =>
{
- spectatorClient.BindUserBeganPlaying(userBeganPlaying, true);
- spectatorClient.OnUserFinishedPlaying += userFinishedPlaying;
+ foreach (var u in users.Result)
+ userMap[u.Id] = u;
+
+ playingUserStates.BindTo(spectatorClient.PlayingUserStates);
+ playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true);
+
spectatorClient.OnNewFrames += userSentFrames;
managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
managerUpdated.BindValueChanged(beatmapUpdated);
- lock (stateLock)
- {
- foreach (var (id, _) in userMap)
- spectatorClient.WatchUser(id);
- }
+ foreach (var (id, _) in userMap)
+ spectatorClient.WatchUser(id);
}));
}
- private Task populateAllUsers()
+ private Task getAllUsers()
{
- var userLookupTasks = new List();
+ var userLookupTasks = new List>();
foreach (var u in userIds)
{
userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task =>
{
if (!task.IsCompletedSuccessfully)
- return;
+ return null;
- lock (stateLock)
- userMap[u] = task.Result;
+ return task.Result;
}));
}
@@ -104,118 +104,119 @@ namespace osu.Game.Screens.Spectate
if (!e.NewValue.TryGetTarget(out var beatmapSet))
return;
- lock (stateLock)
+ foreach (var (userId, _) in userMap)
{
- foreach (var (userId, _) in userMap)
- {
- if (!spectatorClient.TryGetPlayingUserState(userId, out var userState))
- continue;
+ if (!playingUserStates.TryGetValue(userId, out var userState))
+ continue;
- if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID))
- updateGameplayState(userId);
- }
+ if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID))
+ updateGameplayState(userId);
}
}
- private void userBeganPlaying(int userId, SpectatorState state)
+ private void onPlayingUserStatesChanged(object sender, NotifyDictionaryChangedEventArgs e)
+ {
+ switch (e.Action)
+ {
+ case NotifyDictionaryChangedAction.Add:
+ foreach (var (userId, state) in e.NewItems.AsNonNull())
+ onUserStateAdded(userId, state);
+ break;
+
+ case NotifyDictionaryChangedAction.Remove:
+ foreach (var (userId, _) in e.OldItems.AsNonNull())
+ onUserStateRemoved(userId);
+ break;
+
+ case NotifyDictionaryChangedAction.Replace:
+ foreach (var (userId, _) in e.OldItems.AsNonNull())
+ onUserStateRemoved(userId);
+
+ foreach (var (userId, state) in e.NewItems.AsNonNull())
+ onUserStateAdded(userId, state);
+ break;
+ }
+ }
+
+ private void onUserStateAdded(int userId, SpectatorState state)
{
if (state.RulesetID == null || state.BeatmapID == null)
return;
- lock (stateLock)
- {
- if (!userMap.ContainsKey(userId))
- return;
+ if (!userMap.ContainsKey(userId))
+ return;
- // The user may have stopped playing.
- if (!spectatorClient.TryGetPlayingUserState(userId, out _))
- return;
+ Schedule(() => OnUserStateChanged(userId, state));
+ updateGameplayState(userId);
+ }
- Schedule(() => OnUserStateChanged(userId, state));
+ private void onUserStateRemoved(int userId)
+ {
+ if (!userMap.ContainsKey(userId))
+ return;
- updateGameplayState(userId);
- }
+ if (!gameplayStates.TryGetValue(userId, out var gameplayState))
+ return;
+
+ gameplayState.Score.Replay.HasReceivedAllFrames = true;
+
+ gameplayStates.Remove(userId);
+ Schedule(() => EndGameplay(userId));
}
private void updateGameplayState(int userId)
{
- lock (stateLock)
+ Debug.Assert(userMap.ContainsKey(userId));
+
+ var user = userMap[userId];
+ var spectatorState = playingUserStates[userId];
+
+ var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance();
+ if (resolvedRuleset == null)
+ return;
+
+ var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID);
+ if (resolvedBeatmap == null)
+ return;
+
+ var score = new Score
{
- Debug.Assert(userMap.ContainsKey(userId));
-
- // The user may have stopped playing.
- if (!spectatorClient.TryGetPlayingUserState(userId, out var spectatorState))
- return;
-
- var user = userMap[userId];
-
- var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance();
- if (resolvedRuleset == null)
- return;
-
- var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID);
- if (resolvedBeatmap == null)
- return;
-
- var score = new Score
+ ScoreInfo = new ScoreInfo
{
- ScoreInfo = new ScoreInfo
- {
- Beatmap = resolvedBeatmap,
- User = user,
- Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
- Ruleset = resolvedRuleset.RulesetInfo,
- },
- Replay = new Replay { HasReceivedAllFrames = false },
- };
+ Beatmap = resolvedBeatmap,
+ User = user,
+ Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
+ Ruleset = resolvedRuleset.RulesetInfo,
+ },
+ Replay = new Replay { HasReceivedAllFrames = false },
+ };
- var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
+ var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
- gameplayStates[userId] = gameplayState;
- Schedule(() => StartGameplay(userId, gameplayState));
- }
+ gameplayStates[userId] = gameplayState;
+ Schedule(() => StartGameplay(userId, gameplayState));
}
private void userSentFrames(int userId, FrameDataBundle bundle)
{
- lock (stateLock)
+ if (!userMap.ContainsKey(userId))
+ return;
+
+ if (!gameplayStates.TryGetValue(userId, out var gameplayState))
+ return;
+
+ // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock.
+ Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset));
+
+ foreach (var frame in bundle.Frames)
{
- if (!userMap.ContainsKey(userId))
- return;
+ IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame();
+ convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap);
- if (!gameplayStates.TryGetValue(userId, out var gameplayState))
- return;
+ var convertedFrame = (ReplayFrame)convertibleFrame;
+ convertedFrame.Time = frame.Time;
- // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock.
- Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset));
-
- foreach (var frame in bundle.Frames)
- {
- IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame();
- convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap);
-
- var convertedFrame = (ReplayFrame)convertibleFrame;
- convertedFrame.Time = frame.Time;
-
- gameplayState.Score.Replay.Frames.Add(convertedFrame);
- }
- }
- }
-
- private void userFinishedPlaying(int userId, SpectatorState state)
- {
- lock (stateLock)
- {
- if (!userMap.ContainsKey(userId))
- return;
-
- if (!gameplayStates.TryGetValue(userId, out var gameplayState))
- return;
-
- gameplayState.Score.Replay.HasReceivedAllFrames = true;
-
- gameplayStates.Remove(userId);
- Schedule(() => EndGameplay(userId));
+ gameplayState.Score.Replay.Frames.Add(convertedFrame);
}
}
@@ -245,15 +246,12 @@ namespace osu.Game.Screens.Spectate
/// The user to stop spectating.
protected void RemoveUser(int userId)
{
- lock (stateLock)
- {
- userFinishedPlaying(userId, null);
+ onUserStateRemoved(userId);
- userIds.Remove(userId);
- userMap.Remove(userId);
+ userIds.Remove(userId);
+ userMap.Remove(userId);
- spectatorClient.StopWatchingUser(userId);
- }
+ spectatorClient.StopWatchingUser(userId);
}
protected override void Dispose(bool isDisposing)
@@ -262,15 +260,10 @@ namespace osu.Game.Screens.Spectate
if (spectatorClient != null)
{
- spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
- spectatorClient.OnUserFinishedPlaying -= userFinishedPlaying;
spectatorClient.OnNewFrames -= userSentFrames;
- lock (stateLock)
- {
- foreach (var (userId, _) in userMap)
- spectatorClient.StopWatchingUser(userId);
- }
+ foreach (var (userId, _) in userMap)
+ spectatorClient.StopWatchingUser(userId);
}
managerUpdated?.UnbindAll();
diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs
index d13ddcf22b..ba31816a07 100644
--- a/osu.Game/Skinning/DefaultSkin.cs
+++ b/osu.Game/Skinning/DefaultSkin.cs
@@ -14,6 +14,7 @@ using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
+using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osuTK;
using osuTK.Graphics;
@@ -78,6 +79,24 @@ namespace osu.Game.Skinning
combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5);
combo.Anchor = Anchor.TopCentre;
}
+
+ var hitError = container.OfType().FirstOrDefault();
+
+ if (hitError != null)
+ {
+ hitError.Anchor = Anchor.CentreLeft;
+ hitError.Origin = Anchor.CentreLeft;
+ }
+
+ var hitError2 = container.OfType().LastOrDefault();
+
+ if (hitError2 != null)
+ {
+ hitError2.Anchor = Anchor.CentreRight;
+ hitError2.Scale = new Vector2(-1, 1);
+ // origin flipped to match scale above.
+ hitError2.Origin = Anchor.CentreLeft;
+ }
}
})
{
@@ -88,6 +107,8 @@ namespace osu.Game.Skinning
GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter)),
GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.HealthDisplay)),
GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.SongProgress)),
+ GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.BarHitErrorMeter)),
+ GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.BarHitErrorMeter)),
}
};
@@ -114,6 +135,12 @@ namespace osu.Game.Skinning
case HUDSkinComponents.SongProgress:
return new SongProgress();
+
+ case HUDSkinComponents.BarHitErrorMeter:
+ return new BarHitErrorMeter();
+
+ case HUDSkinComponents.ColourHitErrorMeter:
+ return new ColourHitErrorMeter();
}
break;
diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs
index f24b0c71c0..07a94cac7a 100644
--- a/osu.Game/Skinning/Editor/SkinEditor.cs
+++ b/osu.Game/Skinning/Editor/SkinEditor.cs
@@ -19,7 +19,7 @@ using osuTK;
namespace osu.Game.Skinning.Editor
{
[Cached(typeof(SkinEditor))]
- public class SkinEditor : FocusedOverlayContainer
+ public class SkinEditor : VisibilityContainer
{
public const double TRANSITION_DURATION = 500;
diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
index 2d7cae71ff..88020896bb 100644
--- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
+++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs
@@ -65,8 +65,6 @@ namespace osu.Game.Skinning.Editor
if (visibility.NewValue == Visibility.Visible)
{
target.Masking = true;
- target.BorderThickness = 5;
- target.BorderColour = colours.Yellow;
target.AllowScaling = false;
target.RelativePositionAxes = Axes.Both;
@@ -75,7 +73,6 @@ namespace osu.Game.Skinning.Editor
}
else
{
- target.BorderThickness = 0;
target.AllowScaling = true;
target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => target.Masking = false);
diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
index bdb1d1c054..99bd22c0bf 100644
--- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
+++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs
@@ -9,6 +9,7 @@ using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Utils;
using osu.Game.Extensions;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
@@ -19,17 +20,9 @@ namespace osu.Game.Skinning.Editor
{
public class SkinSelectionHandler : SelectionHandler
{
- private Vector2? referenceOrigin;
-
[Resolved]
private SkinEditor skinEditor { get; set; }
- protected override void OnOperationEnded()
- {
- base.OnOperationEnded();
- referenceOrigin = null;
- }
-
public override bool HandleRotation(float angle)
{
if (SelectedBlueprints.Count == 1)
@@ -41,14 +34,13 @@ namespace osu.Game.Skinning.Editor
{
var selectionQuad = getSelectionQuad();
- referenceOrigin ??= selectionQuad.Centre;
-
foreach (var b in SelectedBlueprints)
{
var drawableItem = (Drawable)b.Item;
- var rotatedPosition = RotatePointAroundOrigin(b.ScreenSpaceSelectionPoint, referenceOrigin.Value, angle);
+ var rotatedPosition = RotatePointAroundOrigin(b.ScreenSpaceSelectionPoint, selectionQuad.Centre, angle);
updateDrawablePosition(drawableItem, rotatedPosition);
+
drawableItem.Rotation += angle;
}
}
@@ -64,39 +56,43 @@ namespace osu.Game.Skinning.Editor
adjustScaleFromAnchor(ref scale, anchor);
- var selectionQuad = getSelectionQuad();
+ // the selection quad is always upright, so use an AABB rect to make mutating the values easier.
+ var selectionRect = getSelectionQuad().AABBFloat;
- // the selection quad is always upright, so use a rect to make mutating the values easier.
- var adjustedRect = selectionQuad.AABBFloat;
+ // copy to mutate, as we will need to compare to the original later on.
+ var adjustedRect = selectionRect;
- // for now aspect lock scale adjustments that occur at corners.
- if (!anchor.HasFlagFast(Anchor.x1) && !anchor.HasFlagFast(Anchor.y1))
- scale.Y = scale.X / selectionQuad.Width * selectionQuad.Height;
+ // first, remove any scale axis we are not interested in.
+ if (anchor.HasFlagFast(Anchor.x1)) scale.X = 0;
+ if (anchor.HasFlagFast(Anchor.y1)) scale.Y = 0;
- if (anchor.HasFlagFast(Anchor.x0))
+ bool shouldAspectLock =
+ // for now aspect lock scale adjustments that occur at corners..
+ (!anchor.HasFlagFast(Anchor.x1) && !anchor.HasFlagFast(Anchor.y1))
+ // ..or if any of the selection have been rotated.
+ // this is to avoid requiring skew logic (which would likely not be the user's expected transform anyway).
+ || SelectedBlueprints.Any(b => !Precision.AlmostEquals(((Drawable)b.Item).Rotation, 0));
+
+ if (shouldAspectLock)
{
- adjustedRect.X -= scale.X;
- adjustedRect.Width += scale.X;
- }
- else if (anchor.HasFlagFast(Anchor.x2))
- {
- adjustedRect.Width += scale.X;
+ if (anchor.HasFlagFast(Anchor.x1))
+ // if dragging from the horizontal centre, only a vertical component is available.
+ scale.X = scale.Y / selectionRect.Height * selectionRect.Width;
+ else
+ // in all other cases (arbitrarily) use the horizontal component for aspect lock.
+ scale.Y = scale.X / selectionRect.Width * selectionRect.Height;
}
- if (anchor.HasFlagFast(Anchor.y0))
- {
- adjustedRect.Y -= scale.Y;
- adjustedRect.Height += scale.Y;
- }
- else if (anchor.HasFlagFast(Anchor.y2))
- {
- adjustedRect.Height += scale.Y;
- }
+ if (anchor.HasFlagFast(Anchor.x0)) adjustedRect.X -= scale.X;
+ if (anchor.HasFlagFast(Anchor.y0)) adjustedRect.Y -= scale.Y;
- // scale adjust should match that of the quad itself.
+ adjustedRect.Width += scale.X;
+ adjustedRect.Height += scale.Y;
+
+ // scale adjust applied to each individual item should match that of the quad itself.
var scaledDelta = new Vector2(
- adjustedRect.Width / selectionQuad.Width,
- adjustedRect.Height / selectionQuad.Height
+ adjustedRect.Width / selectionRect.Width,
+ adjustedRect.Height / selectionRect.Height
);
foreach (var b in SelectedBlueprints)
@@ -108,8 +104,8 @@ namespace osu.Game.Skinning.Editor
var relativePositionInOriginal =
new Vector2(
- (screenPosition.X - selectionQuad.TopLeft.X) / selectionQuad.Width,
- (screenPosition.Y - selectionQuad.TopLeft.Y) / selectionQuad.Height
+ (screenPosition.X - selectionRect.TopLeft.X) / selectionRect.Width,
+ (screenPosition.Y - selectionRect.TopLeft.Y) / selectionRect.Height
);
var newPositionInAdjusted = new Vector2(
@@ -184,7 +180,7 @@ namespace osu.Game.Skinning.Editor
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
- IEnumerable createAnchorItems(Func checkFunction, Action applyFunction)
+ IEnumerable createAnchorItems(Func checkFunction, Action applyFunction)
{
var displayableAnchors = new[]
{
@@ -201,7 +197,7 @@ namespace osu.Game.Skinning.Editor
return displayableAnchors.Select(a =>
{
- return new AnchorMenuItem(a, selection, _ => applyFunction(a))
+ return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a))
{
State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) }
};
@@ -256,15 +252,5 @@ namespace osu.Game.Skinning.Editor
if ((reference & Anchor.x0) > 0) scale.X = -scale.X;
if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y;
}
-
- public class AnchorMenuItem : TernaryStateMenuItem
- {
- public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action)
- : base(anchor.ToString(), getNextState, MenuItemType.Standard, action)
- {
- }
-
- private static TernaryState getNextState(TernaryState state) => TernaryState.True;
- }
}
}
diff --git a/osu.Game/Skinning/HUDSkinComponents.cs b/osu.Game/Skinning/HUDSkinComponents.cs
index 2e6c3a9937..ea39c98635 100644
--- a/osu.Game/Skinning/HUDSkinComponents.cs
+++ b/osu.Game/Skinning/HUDSkinComponents.cs
@@ -10,5 +10,7 @@ namespace osu.Game.Skinning
AccuracyCounter,
HealthDisplay,
SongProgress,
+ BarHitErrorMeter,
+ ColourHitErrorMeter,
}
}
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index 6c8d6ee45a..7a64f38840 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -19,6 +19,7 @@ using osu.Game.IO;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
+using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osuTK.Graphics;
namespace osu.Game.Skinning
@@ -342,6 +343,20 @@ namespace osu.Game.Skinning
{
accuracy.Y = container.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y;
}
+
+ var songProgress = container.OfType().FirstOrDefault();
+
+ var hitError = container.OfType().FirstOrDefault();
+
+ if (hitError != null)
+ {
+ hitError.Anchor = Anchor.BottomCentre;
+ hitError.Origin = Anchor.CentreLeft;
+ hitError.Rotation = -90;
+
+ if (songProgress != null)
+ hitError.Y -= SongProgress.MAX_HEIGHT;
+ }
})
{
Children = new[]
@@ -352,6 +367,7 @@ namespace osu.Game.Skinning
GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter)) ?? new DefaultAccuracyCounter(),
GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.HealthDisplay)) ?? new DefaultHealthDisplay(),
GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.SongProgress)) ?? new SongProgress(),
+ GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.BarHitErrorMeter)) ?? new BarHitErrorMeter(),
}
};
diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs
index db344b28dd..c76d1053b2 100644
--- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public const int PLAYER_1_ID = 55;
public const int PLAYER_2_ID = 56;
- [Cached(typeof(StatefulMultiplayerClient))]
+ [Cached(typeof(MultiplayerClient))]
public TestMultiplayerClient Client { get; }
[Cached(typeof(IRoomManager))]
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index 167cf705a7..b12bd8091d 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -20,7 +20,7 @@ using osu.Game.Users;
namespace osu.Game.Tests.Visual.Multiplayer
{
- public class TestMultiplayerClient : StatefulMultiplayerClient
+ public class TestMultiplayerClient : MultiplayerClient
{
public override IBindable IsConnected => isConnected;
private readonly Bindable isConnected = new Bindable(true);
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs
index e57411d04d..1abf4d8f5d 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs
@@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
protected override Container Content => content;
private readonly Container content;
- [Cached(typeof(StatefulMultiplayerClient))]
+ [Cached(typeof(MultiplayerClient))]
public readonly TestMultiplayerClient Client;
[Cached(typeof(IRoomManager))]
diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs
index 78a6bcc3db..2dc77fa72a 100644
--- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs
+++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs
@@ -33,8 +33,12 @@ namespace osu.Game.Tests.Visual
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
+
dependencies.CacheAs(new EditorClock());
+ var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset);
+ dependencies.CacheAs(new EditorBeatmap(playable));
+
return dependencies;
}
diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
new file mode 100644
index 0000000000..3a5ffa8770
--- /dev/null
+++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs
@@ -0,0 +1,110 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Utils;
+using osu.Game.Online.API;
+using osu.Game.Online.Spectator;
+using osu.Game.Replays.Legacy;
+using osu.Game.Scoring;
+
+namespace osu.Game.Tests.Visual.Spectator
+{
+ public class TestSpectatorClient : SpectatorClient
+ {
+ public override IBindable IsConnected { get; } = new Bindable(true);
+
+ private readonly Dictionary userBeatmapDictionary = new Dictionary();
+
+ [Resolved]
+ private IAPIProvider api { get; set; } = null!;
+
+ ///
+ /// Starts play for an arbitrary user.
+ ///
+ /// The user to start play for.
+ /// The playing beatmap id.
+ public void StartPlay(int userId, int beatmapId)
+ {
+ userBeatmapDictionary[userId] = beatmapId;
+ sendPlayingState(userId);
+ }
+
+ ///
+ /// Ends play for an arbitrary user.
+ ///
+ /// The user to end play for.
+ public void EndPlay(int userId)
+ {
+ if (!PlayingUsers.Contains(userId))
+ return;
+
+ ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState
+ {
+ BeatmapID = userBeatmapDictionary[userId],
+ RulesetID = 0,
+ });
+ }
+
+ ///
+ /// Sends frames for an arbitrary user.
+ ///
+ /// The user to send frames for.
+ /// The frame index.
+ /// The number of frames to send.
+ 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);
+ }
+
+ protected override Task BeginPlayingInternal(SpectatorState state)
+ {
+ // Track the local user's playing beatmap ID.
+ Debug.Assert(state.BeatmapID != null);
+ userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value;
+
+ return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state);
+ }
+
+ protected override Task SendFramesInternal(FrameDataBundle data) => ((ISpectatorClient)this).UserSentFrames(api.LocalUser.Value.Id, data);
+
+ protected override Task EndPlayingInternal(SpectatorState state) => ((ISpectatorClient)this).UserFinishedPlaying(api.LocalUser.Value.Id, state);
+
+ protected override Task WatchUserInternal(int userId)
+ {
+ // When newly watching a user, the server sends the playing state immediately.
+ if (PlayingUsers.Contains(userId))
+ sendPlayingState(userId);
+
+ return Task.CompletedTask;
+ }
+
+ protected override Task StopWatchingUserInternal(int userId) => Task.CompletedTask;
+
+ private void sendPlayingState(int userId)
+ {
+ ((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState
+ {
+ BeatmapID = userBeatmapDictionary[userId],
+ RulesetID = 0,
+ });
+ }
+ }
+}
diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs
deleted file mode 100644
index cc8437479d..0000000000
--- a/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System.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;
- }
- }
-}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 587bdaf622..1e3b77cd70 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -29,7 +29,7 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 7ba7a554d6..a2a9ac35fc 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -93,7 +93,7 @@
-
+