Merge branch 'master' into catcher-area-catcher

# Conflicts:
#	osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
This commit is contained in:
ekrctb 2021-07-21 16:45:28 +09:00
commit 179ba3c9a8
115 changed files with 2080 additions and 464 deletions

View File

@ -3,6 +3,7 @@ M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Us
M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals(Fallbacks to ValueType). Use IEquatable<T> or EqualityComparer<T>.Default instead.
M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead.
T:System.IComparable;Don't use non-generic IComparable. Use generic version instead.
T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable<T> instead.
M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText.
M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900)
T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods.

View File

@ -0,0 +1,288 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Catch.Tests
{
[TestFixture]
public class JuiceStreamPathTest
{
[TestCase(1e3, true, false)]
// When the coordinates are large, the slope invariant fails within the specified absolute allowance due to the floating-number precision.
[TestCase(1e9, false, false)]
// Using discrete values sometimes discover more edge cases.
[TestCase(10, true, true)]
public void TestRandomInsertSetPosition(double scale, bool checkSlope, bool integralValues)
{
var rng = new Random(1);
var path = new JuiceStreamPath();
for (int iteration = 0; iteration < 100000; iteration++)
{
if (rng.Next(10) == 0)
path.Clear();
int vertexCount = path.Vertices.Count;
switch (rng.Next(2))
{
case 0:
{
double distance = rng.NextDouble() * scale * 2 - scale;
if (integralValues)
distance = Math.Round(distance);
float oldX = path.PositionAtDistance(distance);
int index = path.InsertVertex(distance);
Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount + 1));
Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance));
Assert.That(path.Vertices[index].X, Is.EqualTo(oldX));
break;
}
case 1:
{
int index = rng.Next(path.Vertices.Count);
double distance = path.Vertices[index].Distance;
float newX = (float)(rng.NextDouble() * scale * 2 - scale);
if (integralValues)
newX = MathF.Round(newX);
path.SetVertexPosition(index, newX);
Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount));
Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance));
Assert.That(path.Vertices[index].X, Is.EqualTo(newX));
break;
}
}
assertInvariants(path.Vertices, checkSlope);
}
}
[Test]
public void TestRemoveVertices()
{
var path = new JuiceStreamPath();
path.Add(10, 5);
path.Add(20, -5);
int removeCount = path.RemoveVertices((v, i) => v.Distance == 10 && i == 1);
Assert.That(removeCount, Is.EqualTo(1));
Assert.That(path.Vertices, Is.EqualTo(new[]
{
new JuiceStreamPathVertex(0, 0),
new JuiceStreamPathVertex(20, -5)
}));
removeCount = path.RemoveVertices((_, i) => i == 0);
Assert.That(removeCount, Is.EqualTo(1));
Assert.That(path.Vertices, Is.EqualTo(new[]
{
new JuiceStreamPathVertex(20, -5)
}));
removeCount = path.RemoveVertices((_, i) => true);
Assert.That(removeCount, Is.EqualTo(1));
Assert.That(path.Vertices, Is.EqualTo(new[]
{
new JuiceStreamPathVertex()
}));
}
[Test]
public void TestResampleVertices()
{
var path = new JuiceStreamPath();
path.Add(-100, -10);
path.Add(100, 50);
path.ResampleVertices(new double[]
{
-50,
0,
70,
120
});
Assert.That(path.Vertices, Is.EqualTo(new[]
{
new JuiceStreamPathVertex(-100, -10),
new JuiceStreamPathVertex(-50, -5),
new JuiceStreamPathVertex(0, 0),
new JuiceStreamPathVertex(70, 35),
new JuiceStreamPathVertex(100, 50),
new JuiceStreamPathVertex(100, 50),
}));
path.Clear();
path.SetVertexPosition(0, 10);
path.ResampleVertices(Array.Empty<double>());
Assert.That(path.Vertices, Is.EqualTo(new[]
{
new JuiceStreamPathVertex(0, 10)
}));
}
[Test]
public void TestRandomConvertFromSliderPath()
{
var rng = new Random(1);
var path = new JuiceStreamPath();
var sliderPath = new SliderPath();
for (int iteration = 0; iteration < 10000; iteration++)
{
sliderPath.ControlPoints.Clear();
do
{
int start = sliderPath.ControlPoints.Count;
do
{
float x = (float)(rng.NextDouble() * 1e3);
float y = (float)(rng.NextDouble() * 1e3);
sliderPath.ControlPoints.Add(new PathControlPoint(new Vector2(x, y)));
} while (rng.Next(2) != 0);
int length = sliderPath.ControlPoints.Count - start + 1;
sliderPath.ControlPoints[start].Type.Value = length <= 2 ? PathType.Linear : length == 3 ? PathType.PerfectCurve : PathType.Bezier;
} while (rng.Next(3) != 0);
if (rng.Next(5) == 0)
sliderPath.ExpectedDistance.Value = rng.NextDouble() * 3e3;
else
sliderPath.ExpectedDistance.Value = null;
path.ConvertFromSliderPath(sliderPath);
Assert.That(path.Vertices[0].Distance, Is.EqualTo(0));
Assert.That(path.Distance, Is.EqualTo(sliderPath.Distance).Within(1e-3));
assertInvariants(path.Vertices, true);
double[] sampleDistances = Enumerable.Range(0, 10)
.Select(_ => rng.NextDouble() * sliderPath.Distance)
.ToArray();
foreach (double distance in sampleDistances)
{
float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X;
Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3));
}
path.ResampleVertices(sampleDistances);
assertInvariants(path.Vertices, true);
foreach (double distance in sampleDistances)
{
float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X;
Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3));
}
}
}
[Test]
public void TestRandomConvertToSliderPath()
{
var rng = new Random(1);
var path = new JuiceStreamPath();
var sliderPath = new SliderPath();
for (int iteration = 0; iteration < 10000; iteration++)
{
path.Clear();
do
{
double distance = rng.NextDouble() * 1e3;
float x = (float)(rng.NextDouble() * 1e3);
path.Add(distance, x);
} while (rng.Next(5) != 0);
float sliderStartY = (float)(rng.NextDouble() * JuiceStreamPath.OSU_PLAYFIELD_HEIGHT);
path.ConvertToSliderPath(sliderPath, sliderStartY);
Assert.That(sliderPath.Distance, Is.EqualTo(path.Distance).Within(1e-3));
Assert.That(sliderPath.ControlPoints[0].Position.Value.X, Is.EqualTo(path.Vertices[0].X));
assertInvariants(path.Vertices, true);
foreach (var point in sliderPath.ControlPoints)
{
Assert.That(point.Type.Value, Is.EqualTo(PathType.Linear).Or.Null);
Assert.That(sliderStartY + point.Position.Value.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT));
}
for (int i = 0; i < 10; i++)
{
double distance = rng.NextDouble() * path.Distance;
float expected = path.PositionAtDistance(distance);
Assert.That(sliderPath.PositionAt(distance / sliderPath.Distance).X, Is.EqualTo(expected).Within(1e-3));
}
}
}
[Test]
public void TestInvalidation()
{
var path = new JuiceStreamPath();
Assert.That(path.InvalidationID, Is.EqualTo(1));
int previousId = path.InvalidationID;
path.InsertVertex(10);
checkNewId();
path.SetVertexPosition(1, 5);
checkNewId();
path.Add(20, 0);
checkNewId();
path.RemoveVertices((v, _) => v.Distance == 20);
checkNewId();
path.ResampleVertices(new double[] { 5, 10, 15 });
checkNewId();
path.Clear();
checkNewId();
path.ConvertFromSliderPath(new SliderPath());
checkNewId();
void checkNewId()
{
Assert.That(path.InvalidationID, Is.Not.EqualTo(previousId));
previousId = path.InvalidationID;
}
}
private void assertInvariants(IReadOnlyList<JuiceStreamPathVertex> vertices, bool checkSlope)
{
Assert.That(vertices, Is.Not.Empty);
for (int i = 0; i < vertices.Count; i++)
{
Assert.That(double.IsFinite(vertices[i].Distance));
Assert.That(float.IsFinite(vertices[i].X));
}
for (int i = 1; i < vertices.Count; i++)
{
Assert.That(vertices[i].Distance, Is.GreaterThanOrEqualTo(vertices[i - 1].Distance));
if (!checkSlope) continue;
float xDiff = Math.Abs(vertices[i].X - vertices[i - 1].X);
double distanceDiff = vertices[i].Distance - vertices[i - 1].Distance;
Assert.That(xDiff, Is.LessThanOrEqualTo(distanceDiff).Within(Precision.FLOAT_EPSILON));
}
}
}
}

View File

@ -9,6 +9,7 @@
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
using osu.Game.Utils;
using osuTK.Graphics;
@ -31,7 +32,7 @@ public Banana()
}
// override any external colour changes with banananana
Color4 IHasComboInformation.GetComboColour(IReadOnlyList<Color4> comboColours) => getBananaColour();
Color4 IHasComboInformation.GetComboColour(ISkin skin) => getBananaColour();
private Color4 getBananaColour()
{

View File

@ -0,0 +1,340 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osuTK;
#nullable enable
namespace osu.Game.Rulesets.Catch.Objects
{
/// <summary>
/// Represents the path of a juice stream.
/// <para>
/// A <see cref="JuiceStream"/> holds a legacy <see cref="SliderPath"/> as the representation of the path.
/// However, the <see cref="SliderPath"/> representation is difficult to work with.
/// This <see cref="JuiceStreamPath"/> represents the path in a more convenient way, a polyline connecting list of <see cref="JuiceStreamPathVertex"/>s.
/// </para>
/// <para>
/// The path can be regarded as a function from the closed interval <c>[Vertices[0].Distance, Vertices[^1].Distance]</c> to the x position, given by <see cref="PositionAtDistance"/>.
/// To ensure the path is convertible to a <see cref="SliderPath"/>, the slope of the function must not be more than <c>1</c> everywhere,
/// and this slope condition is always maintained as an invariant.
/// </para>
/// </summary>
public class JuiceStreamPath
{
/// <summary>
/// The height of legacy osu!standard playfield.
/// The sliders converted by <see cref="ConvertToSliderPath"/> are vertically contained in this height.
/// </summary>
internal const float OSU_PLAYFIELD_HEIGHT = 384;
/// <summary>
/// The list of vertices of the path, which is represented as a polyline connecting the vertices.
/// </summary>
public IReadOnlyList<JuiceStreamPathVertex> Vertices => vertices;
/// <summary>
/// The current version number.
/// This starts from <c>1</c> and incremented whenever this <see cref="JuiceStreamPath"/> is modified.
/// </summary>
public int InvalidationID { get; private set; } = 1;
/// <summary>
/// The difference between first vertex's <see cref="JuiceStreamPathVertex.Distance"/> and last vertex's <see cref="JuiceStreamPathVertex.Distance"/>.
/// </summary>
public double Distance => vertices[^1].Distance - vertices[0].Distance;
/// <remarks>
/// This list should always be non-empty.
/// </remarks>
private readonly List<JuiceStreamPathVertex> vertices = new List<JuiceStreamPathVertex>
{
new JuiceStreamPathVertex()
};
/// <summary>
/// Compute the x-position of the path at the given <paramref name="distance"/>.
/// </summary>
/// <remarks>
/// When the given distance is outside of the path, the x position at the corresponding endpoint is returned,
/// </remarks>
public float PositionAtDistance(double distance)
{
int index = vertexIndexAtDistance(distance);
return positionAtDistance(distance, index);
}
/// <summary>
/// Remove all vertices of this path, then add a new vertex <c>(0, 0)</c>.
/// </summary>
public void Clear()
{
vertices.Clear();
vertices.Add(new JuiceStreamPathVertex());
invalidate();
}
/// <summary>
/// Insert a vertex at given <paramref name="distance"/>.
/// The <see cref="PositionAtDistance"/> is used as the position of the new vertex.
/// Thus, the set of points of the path is not changed (up to floating-point precision).
/// </summary>
/// <returns>The index of the new vertex.</returns>
public int InsertVertex(double distance)
{
if (!double.IsFinite(distance))
throw new ArgumentOutOfRangeException(nameof(distance));
int index = vertexIndexAtDistance(distance);
float x = positionAtDistance(distance, index);
vertices.Insert(index, new JuiceStreamPathVertex(distance, x));
invalidate();
return index;
}
/// <summary>
/// Move the vertex of given <paramref name="index"/> to the given position <paramref name="newX"/>.
/// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards <paramref name="newX"/>.
/// </summary>
public void SetVertexPosition(int index, float newX)
{
if (index < 0 || index >= vertices.Count)
throw new ArgumentOutOfRangeException(nameof(index));
if (!float.IsFinite(newX))
throw new ArgumentOutOfRangeException(nameof(newX));
var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX);
for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--)
{
float clampedX = clampToConnectablePosition(newVertex, vertices[i]);
vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX);
}
for (int i = index + 1; i < vertices.Count; i++)
{
float clampedX = clampToConnectablePosition(newVertex, vertices[i]);
vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX);
}
vertices[index] = newVertex;
invalidate();
}
/// <summary>
/// Add a new vertex at given <paramref name="distance"/> and position.
/// Adjacent vertices are moved when necessary in the same way as <see cref="SetVertexPosition"/>.
/// </summary>
public void Add(double distance, float x)
{
int index = InsertVertex(distance);
SetVertexPosition(index, x);
}
/// <summary>
/// Remove all vertices that satisfy the given <paramref name="predicate"/>.
/// </summary>
/// <remarks>
/// If all vertices are removed, a new vertex <c>(0, 0)</c> is added.
/// </remarks>
/// <param name="predicate">The predicate to determine whether a vertex should be removed given the vertex and its index in the path.</param>
/// <returns>The number of removed vertices.</returns>
public int RemoveVertices(Func<JuiceStreamPathVertex, int, bool> predicate)
{
int index = 0;
int removeCount = vertices.RemoveAll(vertex => predicate(vertex, index++));
if (vertices.Count == 0)
vertices.Add(new JuiceStreamPathVertex());
if (removeCount != 0)
invalidate();
return removeCount;
}
/// <summary>
/// Recreate this path by using difference set of vertices at given distances.
/// In addition to the given <paramref name="sampleDistances"/>, the first vertex and the last vertex are always added to the new path.
/// New vertices use the positions on the original path. Thus, <see cref="PositionAtDistance"/>s at <paramref name="sampleDistances"/> are preserved.
/// </summary>
public void ResampleVertices(IEnumerable<double> sampleDistances)
{
var sampledVertices = new List<JuiceStreamPathVertex>();
foreach (double distance in sampleDistances)
{
if (!double.IsFinite(distance))
throw new ArgumentOutOfRangeException(nameof(sampleDistances));
double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance);
float x = PositionAtDistance(clampedDistance);
sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x));
}
sampledVertices.Sort();
// The first vertex and the last vertex are always used in the result.
vertices.RemoveRange(1, vertices.Count - (vertices.Count == 1 ? 1 : 2));
vertices.InsertRange(1, sampledVertices);
invalidate();
}
/// <summary>
/// Convert a <see cref="SliderPath"/> to list of vertices and write the result to this <see cref="JuiceStreamPath"/>.
/// </summary>
/// <remarks>
/// Duplicated vertices are automatically removed.
/// </remarks>
public void ConvertFromSliderPath(SliderPath sliderPath)
{
var sliderPathVertices = new List<Vector2>();
sliderPath.GetPathToProgress(sliderPathVertices, 0, 1);
double distance = 0;
vertices.Clear();
vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X));
for (int i = 1; i < sliderPathVertices.Count; i++)
{
distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]);
if (!Precision.AlmostEquals(vertices[^1].Distance, distance))
vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X));
}
invalidate();
}
/// <summary>
/// Convert the path of this <see cref="JuiceStreamPath"/> to a <see cref="SliderPath"/> and write the result to <paramref name="sliderPath"/>.
/// The resulting slider is "folded" to make it vertically contained in the playfield `(0..<see cref="OSU_PLAYFIELD_HEIGHT"/>)` assuming the slider start position is <paramref name="sliderStartY"/>.
/// </summary>
public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY)
{
const float margin = 1;
// Note: these two variables and `sliderPath` are modified by the local functions.
double currentDistance = 0;
Vector2 lastPosition = new Vector2(vertices[0].X, 0);
sliderPath.ControlPoints.Clear();
sliderPath.ControlPoints.Add(new PathControlPoint(lastPosition));
for (int i = 1; i < vertices.Count; i++)
{
sliderPath.ControlPoints[^1].Type.Value = PathType.Linear;
float deltaX = vertices[i].X - lastPosition.X;
double length = vertices[i].Distance - currentDistance;
// Should satisfy `deltaX^2 + deltaY^2 = length^2`.
// By invariants, the expression inside the `sqrt` is (almost) non-negative.
double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX));
// When `deltaY` is small, one segment is always enough.
// This case is handled separately to prevent divide-by-zero.
if (deltaY <= OSU_PLAYFIELD_HEIGHT / 2 - margin)
{
float nextX = vertices[i].X;
float nextY = (float)(lastPosition.Y + getYDirection() * deltaY);
addControlPoint(nextX, nextY);
continue;
}
// When `deltaY` is large or when the slider velocity is fast, the segment must be partitioned to subsegments to stay in bounds.
for (double currentProgress = 0; currentProgress < deltaY;)
{
double nextProgress = Math.Min(currentProgress + getMaxDeltaY(), deltaY);
float nextX = (float)(vertices[i - 1].X + nextProgress / deltaY * deltaX);
float nextY = (float)(lastPosition.Y + getYDirection() * (nextProgress - currentProgress));
addControlPoint(nextX, nextY);
currentProgress = nextProgress;
}
}
int getYDirection()
{
float lastSliderY = sliderStartY + lastPosition.Y;
return lastSliderY < OSU_PLAYFIELD_HEIGHT / 2 ? 1 : -1;
}
float getMaxDeltaY()
{
float lastSliderY = sliderStartY + lastPosition.Y;
return Math.Max(lastSliderY, OSU_PLAYFIELD_HEIGHT - lastSliderY) - margin;
}
void addControlPoint(float nextX, float nextY)
{
Vector2 nextPosition = new Vector2(nextX, nextY);
sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition));
currentDistance += Vector2.Distance(lastPosition, nextPosition);
lastPosition = nextPosition;
}
}
/// <summary>
/// Find the index at which a new vertex with <paramref name="distance"/> can be inserted.
/// </summary>
private int vertexIndexAtDistance(double distance)
{
// The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed.
int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity));
return i < 0 ? ~i : i;
}
/// <summary>
/// Compute the position at the given <paramref name="distance"/>, assuming <paramref name="index"/> is the vertex index returned by <see cref="vertexIndexAtDistance"/>.
/// </summary>
private float positionAtDistance(double distance, int index)
{
if (index <= 0)
return vertices[0].X;
if (index >= vertices.Count)
return vertices[^1].X;
double length = vertices[index].Distance - vertices[index - 1].Distance;
if (Precision.AlmostEquals(length, 0))
return vertices[index].X;
float deltaX = vertices[index].X - vertices[index - 1].X;
return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length));
}
/// <summary>
/// Check the two vertices can connected directly while satisfying the slope condition.
/// </summary>
private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0)
{
double xDistance = Math.Abs((double)vertex2.X - vertex1.X);
float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance);
return xDistance <= length + allowance;
}
/// <summary>
/// Move the position of <paramref name="movableVertex"/> towards the position of <paramref name="fixedVertex"/>
/// until the vertex pair satisfies the condition <see cref="canConnect"/>.
/// </summary>
/// <returns>The resulting position of <paramref name="movableVertex"/>.</returns>
private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex)
{
float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance);
return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length);
}
private void invalidate() => InvalidationID++;
}
}

View File

@ -0,0 +1,33 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
#nullable enable
namespace osu.Game.Rulesets.Catch.Objects
{
/// <summary>
/// A vertex of a <see cref="JuiceStreamPath"/>.
/// </summary>
public readonly struct JuiceStreamPathVertex : IComparable<JuiceStreamPathVertex>
{
public readonly double Distance;
public readonly float X;
public JuiceStreamPathVertex(double distance, float x)
{
Distance = distance;
X = x;
}
public int CompareTo(JuiceStreamPathVertex other)
{
int c = Distance.CompareTo(other.Distance);
return c != 0 ? c : X.CompareTo(other.X);
}
public override string ToString() => $"({Distance}, {X})";
}
}

View File

@ -1,10 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.Objects
@ -45,6 +45,6 @@ public CatchHitObject HyperDashTarget
}
}
Color4 IHasComboInformation.GetComboColour(IReadOnlyList<Color4> comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count];
Color4 IHasComboInformation.GetComboColour(ISkin skin) => IHasComboInformation.GetSkinComboColour(this, skin, IndexInBeatmap + 1);
}
}

View File

@ -353,6 +353,7 @@ public class TargetBeatContainer : BeatSyncedContainer
public TargetBeatContainer(double firstHitTime)
{
this.firstHitTime = firstHitTime;
AllowMistimedEventFiring = false;
Divisor = 1;
}
@ -374,8 +375,7 @@ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint,
int timeSignature = (int)timingPoint.TimeSignature;
// play metronome from one measure before the first object.
// TODO: Use BeatSyncClock from https://github.com/ppy/osu/pull/13894.
if (Clock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature)
if (BeatSyncClock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature)
return;
sample.Frequency.Value = beatIndex % timeSignature == 0 ? 1 : 0.5f;

View File

@ -129,14 +129,8 @@ public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
{
switch (lookup)
{
case GlobalSkinColours global:
switch (global)
{
case GlobalSkinColours.ComboColours:
return SkinUtils.As<TValue>(new Bindable<IReadOnlyList<Color4>>(ComboColours));
}
break;
case SkinComboColourLookup comboColour:
return SkinUtils.As<TValue>(new Bindable<Color4>(ComboColours[comboColour.ColourIndex % ComboColours.Count]));
}
throw new NotImplementedException();

View File

@ -248,13 +248,13 @@ public void TestClear()
}
[Test]
public void TestCreateCopyIsDeepClone()
public void TestDeepClone()
{
var cpi = new ControlPointInfo();
cpi.Add(1000, new TimingControlPoint { BeatLength = 500 });
var cpiCopy = cpi.CreateCopy();
var cpiCopy = cpi.DeepClone();
cpiCopy.Add(2000, new TimingControlPoint { BeatLength = 500 });

View File

@ -0,0 +1,33 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Tests.NonVisual
{
[TestFixture]
public class ScoreInfoTest
{
[Test]
public void TestDeepClone()
{
var score = new ScoreInfo();
score.Statistics.Add(HitResult.Good, 10);
score.Rank = ScoreRank.B;
var scoreCopy = score.DeepClone();
score.Statistics[HitResult.Good]++;
score.Rank = ScoreRank.X;
Assert.That(scoreCopy.Statistics[HitResult.Good], Is.EqualTo(10));
Assert.That(score.Statistics[HitResult.Good], Is.EqualTo(11));
Assert.That(scoreCopy.Rank, Is.EqualTo(ScoreRank.B));
Assert.That(score.Rank, Is.EqualTo(ScoreRank.X));
}
}
}

View File

@ -4,13 +4,11 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Tests.Visual.OnlinePlay;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
@ -29,7 +27,6 @@ public class TestSceneLoungeRoomsContainer : OnlinePlayTestScene
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 0.5f,
JoinRequested = joinRequested
};
});
@ -43,11 +40,8 @@ public void TestBasicListChanges()
AddAssert("has 2 rooms", () => container.Rooms.Count == 2);
AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0));
AddStep("select first room", () => container.Rooms.First().Action?.Invoke());
AddStep("select first room", () => container.Rooms.First().Click());
AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First()));
AddStep("join first room", () => container.Rooms.First().Action?.Invoke());
AddAssert("first room joined", () => RoomManager.Rooms.First().Status.Value is JoinedRoomStatus);
}
[Test]
@ -66,9 +60,6 @@ public void TestKeyboardNavigation()
press(Key.Down);
press(Key.Down);
AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last()));
press(Key.Enter);
AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus);
}
[Test]
@ -123,15 +114,12 @@ public void TestRulesetFiltering()
AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3);
}
private bool checkRoomSelected(Room room) => SelectedRoom.Value == room;
private void joinRequested(Room room) => room.Status.Value = new JoinedRoomStatus();
private class JoinedRoomStatus : RoomStatus
[Test]
public void TestPasswordProtectedRooms()
{
public override string Message => "Joined";
public override Color4 GetAppropriateColour(OsuColour colours) => colours.Yellow;
AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true));
}
private bool checkRoomSelected(Room room) => SelectedRoom.Value == room;
}
}

View File

@ -7,6 +7,7 @@
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
@ -22,6 +23,7 @@
using osu.Game.Screens;
using osu.Game.Screens.OnlinePlay.Components;
using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
@ -85,6 +87,154 @@ public void TestEmpty()
// used to test the flow of multiplayer from visual tests.
}
[Test]
public void TestCreateRoomWithoutPassword()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
}
[Test]
public void TestExitMidJoin()
{
Room room = null;
AddStep("create room", () =>
{
room = new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
};
});
AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria());
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("join room and immediately exit", () =>
{
multiplayerScreen.ChildrenOfType<LoungeSubScreen>().Single().Open(room);
Schedule(() => Stack.CurrentScreen.Exit());
});
}
[Test]
public void TestJoinRoomWithoutPassword()
{
AddStep("create room", () =>
{
API.Queue(new CreateRoomRequest(new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
}));
});
AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria());
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("join room", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddUntilStep("wait for join", () => client.Room != null);
}
[Test]
public void TestCreateRoomWithPassword()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Password = { Value = "password" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddAssert("room has password", () => client.APIRoom?.Password.Value == "password");
}
[Test]
public void TestJoinRoomWithPassword()
{
AddStep("create room", () =>
{
API.Queue(new CreateRoomRequest(new Room
{
Name = { Value = "Test Room" },
Password = { Value = "password" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
}));
});
AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria());
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("join room", () => InputManager.Key(Key.Enter));
DrawableRoom.PasswordEntryPopover passwordEntryPopover = null;
AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().FirstOrDefault()) != null);
AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType<TextBox>().First().Text = "password");
AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType<OsuButton>().First().Click());
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddUntilStep("wait for join", () => client.Room != null);
}
[Test]
public void TestLocalPasswordUpdatedWhenMultiplayerSettingsChange()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Password = { Value = "password" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddStep("change password", () => client.ChangeSettings(password: "password2"));
AddUntilStep("local password changed", () => client.APIRoom?.Password.Value == "password2");
}
[Test]
public void TestUserSetToIdleWhenBeatmapDeleted()
{

View File

@ -0,0 +1,105 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Tests.Visual.OnlinePlay;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene
{
protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager;
private LoungeSubScreen loungeScreen;
private Room lastJoinedRoom;
private string lastJoinedPassword;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("push screen", () => LoadScreen(loungeScreen = new MultiplayerLoungeSubScreen()));
AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen());
AddStep("bind to event", () =>
{
lastJoinedRoom = null;
lastJoinedPassword = null;
RoomManager.JoinRoomRequested = onRoomJoined;
});
}
[Test]
public void TestJoinRoomWithoutPassword()
{
AddStep("add room", () => RoomManager.AddRooms(1, withPassword: false));
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("join room", () => InputManager.Key(Key.Enter));
AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First());
AddAssert("room join password correct", () => lastJoinedPassword == null);
}
[Test]
public void TestPopoverHidesOnLeavingScreen()
{
AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true));
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("attempt join room", () => InputManager.Key(Key.Enter));
AddUntilStep("password prompt appeared", () => InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().Any());
AddStep("exit screen", () => Stack.Exit());
AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().Any());
}
[Test]
public void TestJoinRoomWithPassword()
{
DrawableRoom.PasswordEntryPopover passwordEntryPopover = null;
AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true));
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("attempt join room", () => InputManager.Key(Key.Enter));
AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().FirstOrDefault()) != null);
AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType<TextBox>().First().Text = "password");
AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType<OsuButton>().First().Click());
AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First());
AddAssert("room join password correct", () => lastJoinedPassword == "password");
}
[Test]
public void TestJoinRoomWithPasswordViaKeyboardOnly()
{
DrawableRoom.PasswordEntryPopover passwordEntryPopover = null;
AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true));
AddStep("select room", () => InputManager.Key(Key.Down));
AddStep("attempt join room", () => InputManager.Key(Key.Enter));
AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType<DrawableRoom.PasswordEntryPopover>().FirstOrDefault()) != null);
AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType<TextBox>().First().Text = "password");
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First());
AddAssert("room join password correct", () => lastJoinedPassword == "password");
}
private void onRoomJoined(Room room, string password)
{
lastJoinedRoom = room;
lastJoinedPassword = password;
}
}
}

View File

@ -2,8 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
@ -12,40 +16,66 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneRoomStatus : OsuTestScene
{
public TestSceneRoomStatus()
[Test]
public void TestMultipleStatuses()
{
Child = new FillFlowContainer
AddStep("create rooms", () =>
{
RelativeSizeAxes = Axes.Both,
Width = 0.5f,
Children = new Drawable[]
Child = new FillFlowContainer
{
new DrawableRoom(new Room
RelativeSizeAxes = Axes.Both,
Width = 0.5f,
Children = new Drawable[]
{
Name = { Value = "Open - ending in 1 day" },
Status = { Value = new RoomStatusOpen() },
EndDate = { Value = DateTimeOffset.Now.AddDays(1) }
}) { MatchingFilter = true },
new DrawableRoom(new Room
{
Name = { Value = "Playing - ending in 1 day" },
Status = { Value = new RoomStatusPlaying() },
EndDate = { Value = DateTimeOffset.Now.AddDays(1) }
}) { MatchingFilter = true },
new DrawableRoom(new Room
{
Name = { Value = "Ended" },
Status = { Value = new RoomStatusEnded() },
EndDate = { Value = DateTimeOffset.Now }
}) { MatchingFilter = true },
new DrawableRoom(new Room
{
Name = { Value = "Open" },
Status = { Value = new RoomStatusOpen() },
Category = { Value = RoomCategory.Realtime }
}) { MatchingFilter = true },
}
};
new DrawableRoom(new Room
{
Name = { Value = "Open - ending in 1 day" },
Status = { Value = new RoomStatusOpen() },
EndDate = { Value = DateTimeOffset.Now.AddDays(1) }
}) { MatchingFilter = true },
new DrawableRoom(new Room
{
Name = { Value = "Playing - ending in 1 day" },
Status = { Value = new RoomStatusPlaying() },
EndDate = { Value = DateTimeOffset.Now.AddDays(1) }
}) { MatchingFilter = true },
new DrawableRoom(new Room
{
Name = { Value = "Ended" },
Status = { Value = new RoomStatusEnded() },
EndDate = { Value = DateTimeOffset.Now }
}) { MatchingFilter = true },
new DrawableRoom(new Room
{
Name = { Value = "Open" },
Status = { Value = new RoomStatusOpen() },
Category = { Value = RoomCategory.Realtime }
}) { MatchingFilter = true },
}
};
});
}
[Test]
public void TestEnableAndDisablePassword()
{
DrawableRoom drawableRoom = null;
Room room = null;
AddStep("create room", () => Child = drawableRoom = new DrawableRoom(room = new Room
{
Name = { Value = "Room with password" },
Status = { Value = new RoomStatusOpen() },
Category = { Value = RoomCategory.Realtime },
}) { MatchingFilter = true });
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType<DrawableRoom.PasswordProtectedIcon>().Single().Alpha));
AddStep("set password", () => room.Password.Value = "password");
AddAssert("password icon visible", () => Precision.AlmostEquals(1, drawableRoom.ChildrenOfType<DrawableRoom.PasswordProtectedIcon>().Single().Alpha));
AddStep("unset password", () => room.Password.Value = string.Empty);
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType<DrawableRoom.PasswordProtectedIcon>().Single().Alpha));
}
}
}

View File

@ -37,7 +37,7 @@ public void TestScrollSelectedIntoView()
AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First()));
AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke());
AddStep("select last room", () => roomsContainer.Rooms.Last().Click());
AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First()));
AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last()));

View File

@ -152,7 +152,7 @@ public void CreateRoom(Room room, Action<Room> onSuccess = null, Action<string>
onSuccess?.Invoke(room);
}
public void JoinRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null) => throw new NotImplementedException();
public void JoinRoom(Room room, string password, Action<Room> onSuccess = null, Action<string> onError = null) => throw new NotImplementedException();
public void PartRoom() => throw new NotImplementedException();
}

View File

@ -5,18 +5,19 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Timing;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Play;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.UserInterface
@ -24,37 +25,125 @@ namespace osu.Game.Tests.Visual.UserInterface
[TestFixture]
public class TestSceneBeatSyncedContainer : OsuTestScene
{
private readonly NowPlayingOverlay np;
private TestBeatSyncedContainer beatContainer;
public TestSceneBeatSyncedContainer()
private MasterGameplayClockContainer gameplayClockContainer;
[SetUpSteps]
public void SetUpSteps()
{
Clock = new FramedClock();
Clock.ProcessFrame();
AddRange(new Drawable[]
AddStep("Set beatmap", () =>
{
new BeatContainer
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
},
np = new NowPlayingOverlay
{
Origin = Anchor.TopRight,
Anchor = Anchor.TopRight,
}
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
});
AddStep("Create beat sync container", () =>
{
Children = new Drawable[]
{
gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
{
Child = beatContainer = new TestBeatSyncedContainer
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
},
}
};
});
AddStep("Start playback", () => gameplayClockContainer.Start());
}
protected override void LoadComplete()
[TestCase(false)]
[TestCase(true)]
public void TestDisallowMistimedEventFiring(bool allowMistimed)
{
base.LoadComplete();
np.ToggleVisibility();
int? lastBeatIndex = null;
double? lastActuationTime = null;
TimingControlPoint lastTimingPoint = null;
AddStep($"set mistimed to {(allowMistimed ? "allowed" : "disallowed")}", () => beatContainer.AllowMistimedEventFiring = allowMistimed);
AddStep("Set time before zero", () =>
{
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) =>
{
lastActuationTime = gameplayClockContainer.CurrentTime;
lastTimingPoint = timingControlPoint;
lastBeatIndex = i;
beatContainer.NewBeat = null;
};
gameplayClockContainer.Seek(-1000);
});
AddUntilStep("wait for trigger", () => lastBeatIndex != null);
if (!allowMistimed)
{
AddAssert("trigger is near beat length", () => lastActuationTime != null && lastBeatIndex != null && Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE));
}
else
{
AddAssert("trigger is not near beat length", () => lastActuationTime != null && lastBeatIndex != null && !Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE));
}
}
private class BeatContainer : BeatSyncedContainer
[Test]
public void TestNegativeBeatsStillUsingBeatmapTiming()
{
private const int flash_layer_heigth = 150;
int? lastBeatIndex = null;
double? lastBpm = null;
AddStep("Set time before zero", () =>
{
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) =>
{
lastBeatIndex = i;
lastBpm = timingControlPoint.BPM;
};
gameplayClockContainer.Seek(-1000);
});
AddUntilStep("wait for trigger", () => lastBpm != null);
AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128));
AddAssert("beat index is less than zero", () => lastBeatIndex < 0);
}
[Test]
public void TestIdleBeatOnPausedClock()
{
double? lastBpm = null;
AddStep("bind event", () =>
{
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => lastBpm = timingControlPoint.BPM;
});
AddUntilStep("wait for trigger", () => lastBpm != null);
AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128));
AddStep("pause gameplay clock", () =>
{
lastBpm = null;
gameplayClockContainer.Stop();
});
AddUntilStep("wait for trigger", () => lastBpm != null);
AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60));
}
private class TestBeatSyncedContainer : BeatSyncedContainer
{
private const int flash_layer_height = 150;
public new bool AllowMistimedEventFiring
{
get => base.AllowMistimedEventFiring;
set => base.AllowMistimedEventFiring = value;
}
private readonly InfoString timingPointCount;
private readonly InfoString currentTimingPoint;
@ -64,13 +153,11 @@ private class BeatContainer : BeatSyncedContainer
private readonly InfoString adjustedBeatLength;
private readonly InfoString timeUntilNextBeat;
private readonly InfoString timeSinceLastBeat;
private readonly InfoString currentTime;
private readonly Box flashLayer;
[Resolved]
private MusicController musicController { get; set; }
public BeatContainer()
public TestBeatSyncedContainer()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
@ -82,7 +169,7 @@ public BeatContainer()
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Bottom = flash_layer_heigth },
Margin = new MarginPadding { Bottom = flash_layer_height },
Children = new Drawable[]
{
new Box
@ -98,6 +185,7 @@ public BeatContainer()
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
currentTime = new InfoString(@"Current time"),
timingPointCount = new InfoString(@"Timing points amount"),
currentTimingPoint = new InfoString(@"Current timing point"),
beatCount = new InfoString(@"Beats amount (in the current timing point)"),
@ -116,7 +204,7 @@ public BeatContainer()
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.X,
Height = flash_layer_heigth,
Height = flash_layer_height,
Children = new Drawable[]
{
new Box
@ -133,8 +221,13 @@ public BeatContainer()
}
}
};
}
Beatmap.ValueChanged += delegate
protected override void LoadComplete()
{
base.LoadComplete();
Beatmap.BindValueChanged(_ =>
{
timingPointCount.Value = 0;
currentTimingPoint.Value = 0;
@ -144,7 +237,7 @@ public BeatContainer()
adjustedBeatLength.Value = 0;
timeUntilNextBeat.Value = 0;
timeSinceLastBeat.Value = 0;
};
}, true);
}
private List<TimingControlPoint> timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList();
@ -164,7 +257,7 @@ private int calculateBeatCount(TimingControlPoint current)
if (timingPoints.Count == 0) return 0;
if (timingPoints[^1] == current)
return (int)Math.Ceiling((musicController.CurrentTrack.Length - current.Time) / current.BeatLength);
return (int)Math.Ceiling((BeatSyncClock.CurrentTime - current.Time) / current.BeatLength);
return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength);
}
@ -174,8 +267,11 @@ protected override void Update()
base.Update();
timeUntilNextBeat.Value = TimeUntilNextBeat;
timeSinceLastBeat.Value = TimeSinceLastBeat;
currentTime.Value = BeatSyncClock.CurrentTime;
}
public Action<int, TimingControlPoint, EffectControlPoint, ChannelAmplitudes> NewBeat;
protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);
@ -187,7 +283,9 @@ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint,
beatsPerMinute.Value = 60000 / timingPoint.BeatLength;
adjustedBeatLength.Value = timingPoint.BeatLength;
flashLayer.FadeOutFromOne(timingPoint.BeatLength);
flashLayer.FadeOutFromOne(timingPoint.BeatLength / 4);
NewBeat?.Invoke(beatIndex, timingPoint, effectPoint, amplitudes);
}
}
@ -200,7 +298,7 @@ private class InfoString : FillFlowContainer
public double Value
{
set => valueText.Text = $"{value:G}";
set => valueText.Text = $"{value:0.##}";
}
public InfoString(string header)

View File

@ -88,7 +88,7 @@ public void TestModSettingsUnboundWhenCopied()
AddStep("create mods", () =>
{
original = new OsuModDoubleTime();
copy = (OsuModDoubleTime)original.CreateCopy();
copy = (OsuModDoubleTime)original.DeepClone();
});
AddStep("change property", () => original.SpeedChange.Value = 2);
@ -106,7 +106,7 @@ public void TestMultiModSettingsUnboundWhenCopied()
AddStep("create mods", () =>
{
original = new MultiMod(new OsuModDoubleTime());
copy = (MultiMod)original.CreateCopy();
copy = (MultiMod)original.DeepClone();
});
AddStep("change property", () => ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2);

View File

@ -3,11 +3,12 @@
using System;
using osu.Game.Graphics;
using osu.Game.Utils;
using osuTK.Graphics;
namespace osu.Game.Beatmaps.ControlPoints
{
public abstract class ControlPoint : IComparable<ControlPoint>
public abstract class ControlPoint : IComparable<ControlPoint>, IDeepCloneable<ControlPoint>
{
/// <summary>
/// The time at which the control point takes effect.
@ -32,7 +33,7 @@ public abstract class ControlPoint : IComparable<ControlPoint>
/// <summary>
/// Create an unbound copy of this control point.
/// </summary>
public ControlPoint CreateCopy()
public ControlPoint DeepClone()
{
var copy = (ControlPoint)Activator.CreateInstance(GetType());

View File

@ -10,11 +10,12 @@
using osu.Framework.Lists;
using osu.Framework.Utils;
using osu.Game.Screens.Edit;
using osu.Game.Utils;
namespace osu.Game.Beatmaps.ControlPoints
{
[Serializable]
public class ControlPointInfo
public class ControlPointInfo : IDeepCloneable<ControlPointInfo>
{
/// <summary>
/// All control points grouped by time.
@ -350,12 +351,12 @@ private void groupItemRemoved(ControlPoint controlPoint)
}
}
public ControlPointInfo CreateCopy()
public ControlPointInfo DeepClone()
{
var controlPointInfo = new ControlPointInfo();
foreach (var point in AllControlPoints)
controlPointInfo.Add(point.Time, point.CreateCopy());
controlPointInfo.Add(point.Time, point.DeepClone());
return controlPointInfo;
}

View File

@ -1,19 +1,32 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Screens.Play;
namespace osu.Game.Graphics.Containers
{
/// <summary>
/// A container which fires a callback when a new beat is reached.
/// Consumes a parent <see cref="GameplayClock"/> or <see cref="Beatmap"/> (whichever is first available).
/// </summary>
/// <remarks>
/// This container does not set its own clock to the source used for beat matching.
/// This means that if the beat source clock is playing faster or slower, animations may unexpectedly overlap.
/// Make sure this container's Clock is also set to the expected source (or within a parent element which provides this).
///
/// This container will also trigger beat events when the beat matching clock is paused at <see cref="TimingControlPoint.DEFAULT"/>'s BPM.
/// </remarks>
public class BeatSyncedContainer : Container
{
protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>();
private int lastBeat;
private TimingControlPoint lastTimingPoint;
@ -23,6 +36,19 @@ public class BeatSyncedContainer : Container
/// </summary>
protected double EarlyActivationMilliseconds;
/// <summary>
/// While this container automatically applied an animation delay (meaning any animations inside a <see cref="OnNewBeat"/> implementation will
/// always be correctly timed), the event itself can potentially fire away from the related beat.
///
/// By setting this to false, cases where the event is to be fired more than <see cref="MISTIMED_ALLOWANCE"/> from the related beat will be skipped.
/// </summary>
protected bool AllowMistimedEventFiring = true;
/// <summary>
/// The maximum deviance from the actual beat that an <see cref="OnNewBeat"/> can fire when <see cref="AllowMistimedEventFiring"/> is set to false.
/// </summary>
public const double MISTIMED_ALLOWANCE = 16;
/// <summary>
/// The time in milliseconds until the next beat.
/// </summary>
@ -43,16 +69,49 @@ public class BeatSyncedContainer : Container
/// </summary>
public double MinimumBeatLength { get; set; }
/// <summary>
/// Whether this container is currently tracking a beatmap's timing data.
/// </summary>
protected bool IsBeatSyncedWithTrack { get; private set; }
protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
}
[Resolved]
protected IBindable<WorkingBeatmap> Beatmap { get; private set; }
[Resolved(canBeNull: true)]
protected GameplayClock GameplayClock { get; private set; }
protected IClock BeatSyncClock
{
get
{
if (GameplayClock != null)
return GameplayClock;
if (Beatmap.Value.TrackLoaded)
return Beatmap.Value.Track;
return null;
}
}
protected override void Update()
{
ITrack track = null;
IBeatmap beatmap = null;
double currentTrackTime = 0;
TimingControlPoint timingPoint = null;
EffectControlPoint effectPoint = null;
TimingControlPoint timingPoint;
EffectControlPoint effectPoint;
IClock clock = BeatSyncClock;
if (clock == null)
return;
double currentTrackTime = clock.CurrentTime;
if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded)
{
@ -60,23 +119,26 @@ protected override void Update()
beatmap = Beatmap.Value.Beatmap;
}
if (track != null && beatmap != null && track.IsRunning && track.Length > 0)
IsBeatSyncedWithTrack = beatmap != null && clock.IsRunning && track?.Length > 0;
if (IsBeatSyncedWithTrack)
{
currentTrackTime = track.CurrentTime + EarlyActivationMilliseconds;
Debug.Assert(beatmap != null);
timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime);
effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime);
}
IsBeatSyncedWithTrack = timingPoint?.BeatLength > 0;
if (timingPoint == null || !IsBeatSyncedWithTrack)
else
{
// this may be the case where the beat syncing clock has been paused.
// we still want to show an idle animation, so use this container's time instead.
currentTrackTime = Clock.CurrentTime;
timingPoint = TimingControlPoint.DEFAULT;
effectPoint = EffectControlPoint.DEFAULT;
}
currentTrackTime += EarlyActivationMilliseconds;
double beatLength = timingPoint.BeatLength / Divisor;
while (beatLength < MinimumBeatLength)
@ -89,7 +151,7 @@ protected override void Update()
beatIndex--;
TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength;
if (TimeUntilNextBeat < 0)
if (TimeUntilNextBeat <= 0)
TimeUntilNextBeat += beatLength;
TimeSinceLastBeat = beatLength - TimeUntilNextBeat;
@ -97,21 +159,16 @@ protected override void Update()
if (timingPoint == lastTimingPoint && beatIndex == lastBeat)
return;
using (BeginDelayedSequence(-TimeSinceLastBeat))
OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty);
// as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat.
// this can happen after a seek operation.
if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE)
{
using (BeginDelayedSequence(-TimeSinceLastBeat))
OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty);
}
lastBeat = beatIndex;
lastTimingPoint = timingPoint;
}
[BackgroundDependencyLoader]
private void load(IBindable<WorkingBeatmap> beatmap)
{
Beatmap.BindTo(beatmap);
}
protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
{
}
}
}

View File

@ -0,0 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
namespace osu.Game.Online.API
{
public class APIException : InvalidOperationException
{
public APIException(string messsage, Exception innerException)
: base(messsage, innerException)
{
}
}
}

View File

@ -79,7 +79,13 @@ public abstract class APIRequest
/// </summary>
public event APIFailureHandler Failure;
private bool cancelled;
private readonly object completionStateLock = new object();
/// <summary>
/// The state of this request, from an outside perspective.
/// This is used to ensure correct notification events are fired.
/// </summary>
private APIRequestCompletionState completionState;
private Action pendingFailure;
@ -116,12 +122,7 @@ public void Perform(IAPIProvider api)
PostProcess();
API.Schedule(delegate
{
if (cancelled) return;
TriggerSuccess();
});
API.Schedule(TriggerSuccess);
}
/// <summary>
@ -131,16 +132,29 @@ protected virtual void PostProcess()
{
}
private bool succeeded;
internal virtual void TriggerSuccess()
{
succeeded = true;
lock (completionStateLock)
{
if (completionState != APIRequestCompletionState.Waiting)
return;
completionState = APIRequestCompletionState.Completed;
}
Success?.Invoke();
}
internal void TriggerFailure(Exception e)
{
lock (completionStateLock)
{
if (completionState != APIRequestCompletionState.Waiting)
return;
completionState = APIRequestCompletionState.Failed;
}
Failure?.Invoke(e);
}
@ -148,10 +162,14 @@ internal void TriggerFailure(Exception e)
public void Fail(Exception e)
{
if (succeeded || cancelled)
return;
lock (completionStateLock)
{
// while it doesn't matter if code following this check is run more than once,
// this avoids unnecessarily performing work where we are already sure the user has been informed.
if (completionState != APIRequestCompletionState.Waiting)
return;
}
cancelled = true;
WebRequest?.Abort();
string responseString = WebRequest?.GetResponseString();
@ -181,7 +199,11 @@ public void Fail(Exception e)
/// <returns>Whether we are in a failed or cancelled state.</returns>
private bool checkAndScheduleFailure()
{
if (pendingFailure == null) return cancelled;
lock (completionStateLock)
{
if (pendingFailure == null)
return completionState == APIRequestCompletionState.Failed;
}
if (API == null)
pendingFailure();
@ -199,14 +221,6 @@ private class DisplayableError
}
}
public class APIException : InvalidOperationException
{
public APIException(string messsage, Exception innerException)
: base(messsage, innerException)
{
}
}
public delegate void APIFailureHandler(Exception e);
public delegate void APISuccessHandler();

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Online.API
{
public enum APIRequestCompletionState
{
/// <summary>
/// Not yet run or currently waiting on response.
/// </summary>
Waiting,
/// <summary>
/// Ran to completion.
/// </summary>
Completed,
/// <summary>
/// Cancelled or failed due to error.
/// </summary>
Failed
}
}

View File

@ -26,9 +26,9 @@ public GetUserBeatmapsRequest(long userId, BeatmapSetType type, int page = 0, in
public enum BeatmapSetType
{
Favourite,
RankedAndApproved,
Ranked,
Loved,
Unranked,
Pending,
Graveyard
}
}

View File

@ -15,6 +15,16 @@ public interface IMultiplayerLoungeServer
/// </summary>
/// <param name="roomId">The databased room ID.</param>
/// <exception cref="InvalidStateException">If the user is already in the requested (or another) room.</exception>
/// <exception cref="InvalidPasswordException">If the room required a password.</exception>
Task<MultiplayerRoom> JoinRoom(long roomId);
/// <summary>
/// Request to join a multiplayer room with a provided password.
/// </summary>
/// <param name="roomId">The databased room ID.</param>
/// <param name="password">The password for the join request.</param>
/// <exception cref="InvalidStateException">If the user is already in the requested (or another) room.</exception>
/// <exception cref="InvalidPasswordException">If the room provided password was incorrect.</exception>
Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string password);
}
}

View File

@ -0,0 +1,22 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Runtime.Serialization;
using Microsoft.AspNetCore.SignalR;
namespace osu.Game.Online.Multiplayer
{
[Serializable]
public class InvalidPasswordException : HubException
{
public InvalidPasswordException()
{
}
protected InvalidPasswordException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
}

View File

@ -92,7 +92,7 @@ public bool IsHost
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
private Room? apiRoom;
protected Room? APIRoom { get; private set; }
[BackgroundDependencyLoader]
private void load()
@ -115,7 +115,8 @@ private void load()
/// Joins the <see cref="MultiplayerRoom"/> for a given API <see cref="Room"/>.
/// </summary>
/// <param name="room">The API <see cref="Room"/>.</param>
public async Task JoinRoom(Room room)
/// <param name="password">An optional password to use for the join operation.</param>
public async Task JoinRoom(Room room, string? password = null)
{
var cancellationSource = joinCancellationSource = new CancellationTokenSource();
@ -127,7 +128,7 @@ await joinOrLeaveTaskChain.Add(async () =>
Debug.Assert(room.RoomID.Value != null);
// Join the server-side room.
var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
var joinedRoom = await JoinRoom(room.RoomID.Value.Value, password ?? room.Password.Value).ConfigureAwait(false);
Debug.Assert(joinedRoom != null);
// Populate users.
@ -138,7 +139,7 @@ await joinOrLeaveTaskChain.Add(async () =>
await scheduleAsync(() =>
{
Room = joinedRoom;
apiRoom = room;
APIRoom = room;
foreach (var user in joinedRoom.Users)
updateUserPlayingState(user.UserID, user.State);
}, cancellationSource.Token).ConfigureAwait(false);
@ -152,8 +153,9 @@ await scheduleAsync(() =>
/// Joins the <see cref="MultiplayerRoom"/> with a given ID.
/// </summary>
/// <param name="roomId">The room ID.</param>
/// <param name="password">An optional password to use when joining the room.</param>
/// <returns>The joined <see cref="MultiplayerRoom"/>.</returns>
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId);
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null);
public Task LeaveRoom()
{
@ -166,7 +168,7 @@ public Task LeaveRoom()
// 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;
APIRoom = null;
Room = null;
CurrentMatchPlayingUserIds.Clear();
@ -189,8 +191,9 @@ public Task LeaveRoom()
/// A room must be joined for this to have any effect.
/// </remarks>
/// <param name="name">The new room name, if any.</param>
/// <param name="password">The new password, if any.</param>
/// <param name="item">The new room playlist item, if any.</param>
public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistItem> item = default)
public Task ChangeSettings(Optional<string> name = default, Optional<string> password = default, Optional<PlaylistItem> item = default)
{
if (Room == null)
throw new InvalidOperationException("Must be joined to a match to change settings.");
@ -212,6 +215,7 @@ public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistIte
return ChangeSettings(new MultiplayerRoomSettings
{
Name = name.GetOr(Room.Settings.Name),
Password = password.GetOr(Room.Settings.Password),
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
@ -301,22 +305,22 @@ Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Debug.Assert(APIRoom != null);
Room.State = state;
switch (state)
{
case MultiplayerRoomState.Open:
apiRoom.Status.Value = new RoomStatusOpen();
APIRoom.Status.Value = new RoomStatusOpen();
break;
case MultiplayerRoomState.Playing:
apiRoom.Status.Value = new RoomStatusPlaying();
APIRoom.Status.Value = new RoomStatusPlaying();
break;
case MultiplayerRoomState.Closed:
apiRoom.Status.Value = new RoomStatusEnded();
APIRoom.Status.Value = new RoomStatusEnded();
break;
}
@ -377,12 +381,12 @@ Task IMultiplayerClient.HostChanged(int userId)
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Debug.Assert(APIRoom != null);
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
Room.Host = user;
apiRoom.Host.Value = user?.User;
APIRoom.Host.Value = user?.User;
RoomUpdated?.Invoke();
}, false);
@ -525,11 +529,12 @@ private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, Cancellat
if (Room == null)
return;
Debug.Assert(apiRoom != null);
Debug.Assert(APIRoom != null);
// Update a few properties of the room instantaneously.
Room.Settings = settings;
apiRoom.Name.Value = Room.Settings.Name;
APIRoom.Name.Value = Room.Settings.Name;
APIRoom.Password.Value = Room.Settings.Password;
// 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.
@ -551,7 +556,7 @@ private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo bea
if (Room == null || !Room.Settings.Equals(settings))
return;
Debug.Assert(apiRoom != null);
Debug.Assert(APIRoom != null);
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
beatmap.MD5Hash = settings.BeatmapChecksum;
@ -561,7 +566,7 @@ private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo bea
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);
var playlistItem = APIRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
if (playlistItem != null)
updateItem(playlistItem);
@ -569,7 +574,7 @@ private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo bea
{
// An existing playlist item does not exist, so append a new one.
updateItem(playlistItem = new PlaylistItem());
apiRoom.Playlist.Add(playlistItem);
APIRoom.Playlist.Add(playlistItem);
}
CurrentMatchPlayingItem.Value = playlistItem;

View File

@ -36,12 +36,16 @@ public class MultiplayerRoomSettings : IEquatable<MultiplayerRoomSettings>
[Key(6)]
public long PlaylistItemId { get; set; }
[Key(7)]
public string Password { get; set; } = string.Empty;
public bool Equals(MultiplayerRoomSettings other)
=> BeatmapID == other.BeatmapID
&& BeatmapChecksum == other.BeatmapChecksum
&& RequiredMods.SequenceEqual(other.RequiredMods)
&& AllowedMods.SequenceEqual(other.AllowedMods)
&& RulesetID == other.RulesetID
&& Password.Equals(other.Password, StringComparison.Ordinal)
&& Name.Equals(other.Name, StringComparison.Ordinal)
&& PlaylistItemId == other.PlaylistItemId;
@ -49,6 +53,7 @@ public override string ToString() => $"Name:{Name}"
+ $" Beatmap:{BeatmapID} ({BeatmapChecksum})"
+ $" RequiredMods:{string.Join(',', RequiredMods)}"
+ $" AllowedMods:{string.Join(',', AllowedMods)}"
+ $" Password:{(string.IsNullOrEmpty(Password) ? "no" : "yes")}"
+ $" Ruleset:{RulesetID}"
+ $" Item:{PlaylistItemId}";
}

View File

@ -62,12 +62,12 @@ private void load(IAPIProvider api)
}
}
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
protected override Task<MultiplayerRoom> JoinRoom(long roomId, string? password = null)
{
if (!IsConnected.Value)
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty);
}
protected override Task LeaveRoomInternal()

View File

@ -70,7 +70,7 @@ private bool pollIfNecessary()
return true;
}
// not ennough time has passed since the last poll. we do want to schedule a poll to happen, though.
// not enough time has passed since the last poll. we do want to schedule a poll to happen, though.
scheduleNextPoll();
return false;
}

View File

@ -9,11 +9,13 @@ namespace osu.Game.Online.Rooms
{
public class JoinRoomRequest : APIRequest
{
private readonly Room room;
public readonly Room Room;
public readonly string Password;
public JoinRoomRequest(Room room)
public JoinRoomRequest(Room room, string password)
{
this.room = room;
Room = room;
Password = password;
}
protected override WebRequest CreateWebRequest()
@ -23,6 +25,7 @@ protected override WebRequest CreateWebRequest()
return req;
}
protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}";
// Todo: Password needs to be specified here rather than via AddParameter() because this is a PUT request. May be a framework bug.
protected override string Target => $"rooms/{Room.RoomID.Value}/users/{User.Id}?password={Password}";
}
}

View File

@ -10,10 +10,11 @@
using osu.Game.Online.Rooms.GameTypes;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Users;
using osu.Game.Utils;
namespace osu.Game.Online.Rooms
{
public class Room
public class Room : IDeepCloneable<Room>
{
[Cached]
[JsonProperty("id")]
@ -48,10 +49,6 @@ private RoomCategory category
set => Category.Value = value;
}
[Cached]
[JsonIgnore]
public readonly Bindable<TimeSpan?> Duration = new Bindable<TimeSpan?>();
[Cached]
[JsonIgnore]
public readonly Bindable<int?> MaxAttempts = new Bindable<int?>();
@ -76,6 +73,9 @@ private RoomCategory category
[JsonProperty("current_user_score")]
public readonly Bindable<PlaylistAggregateScore> UserScore = new Bindable<PlaylistAggregateScore>();
[JsonProperty("has_password")]
public readonly BindableBool HasPassword = new BindableBool();
[Cached]
[JsonProperty("recent_participants")]
public readonly BindableList<User> RecentParticipants = new BindableList<User>();
@ -84,6 +84,16 @@ private RoomCategory category
[JsonProperty("participant_count")]
public readonly Bindable<int> ParticipantCount = new Bindable<int>();
#region Properties only used for room creation request
[Cached(Name = nameof(Password))]
[JsonProperty("password")]
public readonly Bindable<string> Password = new Bindable<string>();
[Cached]
[JsonIgnore]
public readonly Bindable<TimeSpan?> Duration = new Bindable<TimeSpan?>();
[JsonProperty("duration")]
private int? duration
{
@ -97,6 +107,8 @@ private int? duration
}
}
#endregion
// Only supports retrieval for now
[Cached]
[JsonProperty("ends_at")]
@ -116,11 +128,16 @@ private int? maxAttempts
[JsonIgnore]
public readonly Bindable<int> Position = new Bindable<int>(-1);
public Room()
{
Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue));
}
/// <summary>
/// Create a copy of this room without online information.
/// Should be used to create a local copy of a room for submitting in the future.
/// </summary>
public Room CreateCopy()
public Room DeepClone()
{
var copy = new Room();
@ -144,6 +161,7 @@ public void CopyFrom(Room other)
ChannelId.Value = other.ChannelId.Value;
Status.Value = other.Status.Value;
Availability.Value = other.Availability.Value;
HasPassword.Value = other.HasPassword.Value;
Type.Value = other.Type.Value;
MaxParticipants.Value = other.MaxParticipants.Value;
ParticipantCount.Value = other.ParticipantCount.Value;

View File

@ -13,6 +13,7 @@
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
@ -341,7 +342,11 @@ List<ScoreInfo> getBeatmapScores(BeatmapSetInfo set)
globalBindings = new GlobalActionContainer(this)
};
MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both };
MenuCursorContainer.Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }
};
base.Content.Add(CreateScalingContainer().WithChildren(mainContent));

View File

@ -429,7 +429,7 @@ private void updateAvailableMods()
if (!Stacked)
modEnumeration = ModUtils.FlattenMods(modEnumeration);
section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null).Select(m => m.CreateCopy());
section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null).Select(m => m.DeepClone());
}
updateSelectedButtons();

View File

@ -8,6 +8,7 @@
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Textures;
using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using osuTK;
@ -119,12 +120,12 @@ private void load(OverlayColourProvider colourProvider, TextureStore textures)
{
hiddenDetailGlobal = new OverlinedInfoContainer
{
Title = "Global Ranking",
Title = UsersStrings.ShowRankGlobalSimple,
LineColour = colourProvider.Highlight1
},
hiddenDetailCountry = new OverlinedInfoContainer
{
Title = "Country Ranking",
Title = UsersStrings.ShowRankCountrySimple,
LineColour = colourProvider.Highlight1
},
}

View File

@ -10,6 +10,7 @@
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Profile.Header.Components
@ -18,7 +19,7 @@ public class ExpandDetailsButton : ProfileHeaderButton
{
public readonly BindableBool DetailsVisible = new BindableBool();
public override LocalisableString TooltipText => DetailsVisible.Value ? "collapse" : "expand";
public override LocalisableString TooltipText => DetailsVisible.Value ? CommonStrings.ButtonsCollapse : CommonStrings.ButtonsExpand;
private SpriteIcon icon;
private Sample sampleOpen;

View File

@ -5,6 +5,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components
@ -13,7 +14,7 @@ public class FollowersButton : ProfileHeaderStatisticsButton
{
public readonly Bindable<User> User = new Bindable<User>();
public override LocalisableString TooltipText => "followers";
public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled;
protected override IconUsage Icon => FontAwesome.Solid.User;

View File

@ -11,6 +11,7 @@
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components
@ -19,13 +20,13 @@ public class LevelBadge : CompositeDrawable, IHasTooltip
{
public readonly Bindable<User> User = new Bindable<User>();
public LocalisableString TooltipText { get; }
public LocalisableString TooltipText { get; private set; }
private OsuSpriteText levelText;
public LevelBadge()
{
TooltipText = "level";
TooltipText = UsersStrings.ShowStatsLevel("0");
}
[BackgroundDependencyLoader]
@ -53,6 +54,7 @@ private void load(OsuColour colours, TextureStore textures)
private void updateLevel(User user)
{
levelText.Text = user?.Statistics?.Level.Current.ToString() ?? "0";
TooltipText = UsersStrings.ShowStatsLevel(user?.Statistics?.Level.Current.ToString());
}
}
}

View File

@ -10,6 +10,7 @@
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using osuTK.Graphics;
@ -26,7 +27,7 @@ public class LevelProgressBar : CompositeDrawable, IHasTooltip
public LevelProgressBar()
{
TooltipText = "progress to next level";
TooltipText = UsersStrings.ShowStatsLevelProgress;
}
[BackgroundDependencyLoader]

View File

@ -5,6 +5,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components
@ -13,7 +14,7 @@ public class MappingSubscribersButton : ProfileHeaderStatisticsButton
{
public readonly Bindable<User> User = new Bindable<User>();
public override LocalisableString TooltipText => "mapping subscribers";
public override LocalisableString TooltipText => FollowsStrings.MappingFollowers;
protected override IconUsage Icon => FontAwesome.Solid.Bell;

View File

@ -8,6 +8,7 @@
using osu.Framework.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Chat;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using osuTK;
@ -17,7 +18,7 @@ public class MessageUserButton : ProfileHeaderButton
{
public readonly Bindable<User> User = new Bindable<User>();
public override LocalisableString TooltipText => "send message";
public override LocalisableString TooltipText => UsersStrings.CardSendMessage;
[Resolved(CanBeNull = true)]
private ChannelManager channelManager { get; set; }

View File

@ -4,6 +4,7 @@
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK.Graphics;
@ -16,7 +17,7 @@ public class OverlinedInfoContainer : CompositeDrawable
private readonly OsuSpriteText title;
private readonly OsuSpriteText content;
public string Title
public LocalisableString Title
{
set => title.Text = value;
}

View File

@ -7,6 +7,7 @@
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components
@ -31,7 +32,7 @@ private void load(OverlayColourProvider colourProvider)
{
InternalChild = info = new OverlinedInfoContainer
{
Title = "Total Play Time",
Title = UsersStrings.ShowStatsPlayTime,
LineColour = colourProvider.Highlight1,
};

View File

@ -12,6 +12,7 @@
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using osuTK;
@ -68,7 +69,7 @@ public PreviousUsernames()
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Text = @"formerly known as",
Text = UsersStrings.ShowPreviousUsernames,
Font = OsuFont.GetFont(size: 10, italics: true)
}
},

View File

@ -9,6 +9,7 @@
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components
@ -27,7 +28,7 @@ public RankGraph()
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "No recent plays",
Text = UsersStrings.ShowExtraUnranked,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular)
});
}
@ -74,7 +75,7 @@ protected override object GetTooltipContent(int index, int rank)
private class RankGraphTooltip : UserGraphTooltip
{
public RankGraphTooltip()
: base("Global Ranking")
: base(UsersStrings.ShowRankGlobalSimple)
{
}

View File

@ -10,6 +10,7 @@
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Header.Components
{
@ -19,7 +20,7 @@ public class SupporterIcon : CompositeDrawable, IHasTooltip
private readonly FillFlowContainer iconContainer;
private readonly CircularContainer content;
public LocalisableString TooltipText => "osu!supporter";
public LocalisableString TooltipText => UsersStrings.ShowIsSupporter;
public int SupportLevel
{

View File

@ -11,6 +11,7 @@
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Scoring;
using osu.Game.Users;
using osuTK;
@ -100,7 +101,7 @@ private void load(OverlayColourProvider colourProvider, OsuColour colours)
},
medalInfo = new OverlinedInfoContainer
{
Title = "Medals",
Title = UsersStrings.ShowStatsMedals,
LineColour = colours.GreenLight,
},
ppInfo = new OverlinedInfoContainer
@ -151,12 +152,12 @@ private void load(OverlayColourProvider colourProvider, OsuColour colours)
{
detailGlobalRank = new OverlinedInfoContainer(true, 110)
{
Title = "Global Ranking",
Title = UsersStrings.ShowRankGlobalSimple,
LineColour = colourProvider.Highlight1,
},
detailCountryRank = new OverlinedInfoContainer(false, 110)
{
Title = "Country Ranking",
Title = UsersStrings.ShowRankCountrySimple,
LineColour = colourProvider.Highlight1,
},
}

View File

@ -7,11 +7,13 @@
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using osu.Game.Users.Drawables;
using osuTK;
@ -179,19 +181,19 @@ private void updateUser(User user)
if (user?.Statistics != null)
{
userStats.Add(new UserStatsLine("Ranked Score", user.Statistics.RankedScore.ToString("#,##0")));
userStats.Add(new UserStatsLine("Hit Accuracy", user.Statistics.DisplayAccuracy));
userStats.Add(new UserStatsLine("Play Count", user.Statistics.PlayCount.ToString("#,##0")));
userStats.Add(new UserStatsLine("Total Score", user.Statistics.TotalScore.ToString("#,##0")));
userStats.Add(new UserStatsLine("Total Hits", user.Statistics.TotalHits.ToString("#,##0")));
userStats.Add(new UserStatsLine("Maximum Combo", user.Statistics.MaxCombo.ToString("#,##0")));
userStats.Add(new UserStatsLine("Replays Watched by Others", user.Statistics.ReplaysWatched.ToString("#,##0")));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsRankedScore, user.Statistics.RankedScore.ToString("#,##0")));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsHitAccuracy, user.Statistics.DisplayAccuracy));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsPlayCount, user.Statistics.PlayCount.ToString("#,##0")));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalScore, user.Statistics.TotalScore.ToString("#,##0")));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalHits, user.Statistics.TotalHits.ToString("#,##0")));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsMaximumCombo, user.Statistics.MaxCombo.ToString("#,##0")));
userStats.Add(new UserStatsLine(UsersStrings.ShowStatsReplaysWatchedByOthers, user.Statistics.ReplaysWatched.ToString("#,##0")));
}
}
private class UserStatsLine : Container
{
public UserStatsLine(string left, string right)
public UserStatsLine(LocalisableString left, string right)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;

View File

@ -7,12 +7,14 @@
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Overlays.Profile.Header;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile
{
public class ProfileHeader : TabControlOverlayHeader<string>
public class ProfileHeader : TabControlOverlayHeader<LocalisableString>
{
private UserCoverBackground coverContainer;
@ -27,8 +29,8 @@ public ProfileHeader()
User.ValueChanged += e => updateDisplay(e.NewValue);
TabControl.AddItem("info");
TabControl.AddItem("modding");
TabControl.AddItem(LayoutStrings.HeaderUsersShow);
TabControl.AddItem(LayoutStrings.HeaderUsersModding);
centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true);
}
@ -96,7 +98,7 @@ private class ProfileHeaderTitle : OverlayTitle
{
public ProfileHeaderTitle()
{
Title = "player info";
Title = PageTitleStrings.MainUsersControllerDefault;
IconTexture = "Icons/Hexacons/profile";
}
}

View File

@ -8,6 +8,7 @@
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Sprites;
@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Profile
{
public abstract class ProfileSection : Container
{
public abstract string Title { get; }
public abstract LocalisableString Title { get; }
public abstract string Identifier { get; }

View File

@ -1,12 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class AboutSection : ProfileSection
{
public override string Title => "me!";
public override LocalisableString Title => UsersStrings.ShowExtraMeTitle;
public override string Identifier => "me";
public override string Identifier => @"me";
}
}

View File

@ -5,6 +5,7 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@ -19,7 +20,7 @@ public class PaginatedBeatmapContainer : PaginatedProfileSubsection<APIBeatmapSe
private const float panel_padding = 10f;
private readonly BeatmapSetType type;
public PaginatedBeatmapContainer(BeatmapSetType type, Bindable<User> user, string headerText)
public PaginatedBeatmapContainer(BeatmapSetType type, Bindable<User> user, LocalisableString headerText)
: base(user, headerText)
{
this.type = type;
@ -45,11 +46,11 @@ protected override int GetCount(User user)
case BeatmapSetType.Loved:
return user.LovedBeatmapsetCount;
case BeatmapSetType.RankedAndApproved:
return user.RankedAndApprovedBeatmapsetCount;
case BeatmapSetType.Ranked:
return user.RankedBeatmapsetCount;
case BeatmapSetType.Unranked:
return user.UnrankedBeatmapsetCount;
case BeatmapSetType.Pending:
return user.PendingBeatmapsetCount;
default:
return 0;

View File

@ -1,26 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Profile.Sections.Beatmaps;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class BeatmapsSection : ProfileSection
{
public override string Title => "Beatmaps";
public override LocalisableString Title => UsersStrings.ShowExtraBeatmapsTitle;
public override string Identifier => "beatmaps";
public override string Identifier => @"beatmaps";
public BeatmapsSection()
{
Children = new[]
{
new PaginatedBeatmapContainer(BeatmapSetType.Favourite, User, "Favourite Beatmaps"),
new PaginatedBeatmapContainer(BeatmapSetType.RankedAndApproved, User, "Ranked & Approved Beatmaps"),
new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, "Loved Beatmaps"),
new PaginatedBeatmapContainer(BeatmapSetType.Unranked, User, "Pending Beatmaps"),
new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, "Graveyarded Beatmaps")
new PaginatedBeatmapContainer(BeatmapSetType.Favourite, User, UsersStrings.ShowExtraBeatmapsFavouriteTitle),
new PaginatedBeatmapContainer(BeatmapSetType.Ranked, User, UsersStrings.ShowExtraBeatmapsRankedTitle),
new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, UsersStrings.ShowExtraBeatmapsLovedTitle),
new PaginatedBeatmapContainer(BeatmapSetType.Pending, User, UsersStrings.ShowExtraBeatmapsPendingTitle),
new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle)
};
}
}

View File

@ -6,6 +6,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Users;
using static osu.Game.Users.User;
@ -18,9 +19,9 @@ public abstract class ChartProfileSubsection : ProfileSubsection
/// <summary>
/// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the history graph tooltip.
/// </summary>
protected abstract string GraphCounterName { get; }
protected abstract LocalisableString GraphCounterName { get; }
protected ChartProfileSubsection(Bindable<User> user, string headerText)
protected ChartProfileSubsection(Bindable<User> user, LocalisableString headerText)
: base(user, headerText)
{
}

View File

@ -13,6 +13,7 @@
using osuTK;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections.Historical
{
@ -143,7 +144,7 @@ public MostPlayedBeatmapMetadataContainer(BeatmapInfo beatmap)
private class PlayCountText : CompositeDrawable, IHasTooltip
{
public LocalisableString TooltipText => "times played";
public LocalisableString TooltipText => UsersStrings.ShowExtraHistoricalMostPlayedCount;
public PlayCountText(int playCount)
{

View File

@ -9,6 +9,7 @@
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Sections.Historical
@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection<APIUserMostPlayedBeatmap>
{
public PaginatedMostPlayedBeatmapContainer(Bindable<User> user)
: base(user, "Most Played Beatmaps")
: base(user, UsersStrings.ShowExtraHistoricalMostPlayedTitle)
{
ItemsPerPage = 5;
}

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using static osu.Game.Users.User;
@ -9,10 +11,10 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
public class PlayHistorySubsection : ChartProfileSubsection
{
protected override string GraphCounterName => "Plays";
protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalMonthlyPlaycountsCountLabel;
public PlayHistorySubsection(Bindable<User> user)
: base(user, "Play History")
: base(user, UsersStrings.ShowExtraHistoricalMonthlyPlaycountsTitle)
{
}

View File

@ -12,6 +12,7 @@
using osu.Game.Graphics;
using osu.Framework.Graphics.Shapes;
using osuTK;
using osu.Framework.Localisation;
using static osu.Game.Users.User;
namespace osu.Game.Overlays.Profile.Sections.Historical
@ -42,7 +43,7 @@ public UserHistoryCount[] Values
private readonly Container<TickLine> rowLinesContainer;
private readonly Container<TickLine> columnLinesContainer;
public ProfileLineChart(string graphCounterName)
public ProfileLineChart(LocalisableString graphCounterName)
{
RelativeSizeAxes = Axes.X;
Height = 250;

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users;
using static osu.Game.Users.User;
@ -9,10 +11,10 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
{
public class ReplaysSubsection : ChartProfileSubsection
{
protected override string GraphCounterName => "Replays Watched";
protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalReplaysWatchedCountsCountLabel;
public ReplaysSubsection(Bindable<User> user)
: base(user, "Replays Watched History")
: base(user, UsersStrings.ShowExtraHistoricalReplaysWatchedCountsTitle)
{
}

View File

@ -5,13 +5,14 @@
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Localisation;
using static osu.Game.Users.User;
namespace osu.Game.Overlays.Profile.Sections.Historical
{
public class UserHistoryGraph : UserGraph<DateTime, long>
{
private readonly string tooltipCounterName;
private readonly LocalisableString tooltipCounterName;
[CanBeNull]
public UserHistoryCount[] Values
@ -19,7 +20,7 @@ public UserHistoryCount[] Values
set => Data = value?.Select(v => new KeyValuePair<DateTime, long>(v.Date, v.Count)).ToArray();
}
public UserHistoryGraph(string tooltipCounterName)
public UserHistoryGraph(LocalisableString tooltipCounterName)
{
this.tooltipCounterName = tooltipCounterName;
}
@ -40,9 +41,9 @@ protected override object GetTooltipContent(DateTime date, long playCount)
protected class HistoryGraphTooltip : UserGraphTooltip
{
private readonly string tooltipCounterName;
private readonly LocalisableString tooltipCounterName;
public HistoryGraphTooltip(string tooltipCounterName)
public HistoryGraphTooltip(LocalisableString tooltipCounterName)
: base(tooltipCounterName)
{
this.tooltipCounterName = tooltipCounterName;
@ -61,7 +62,7 @@ public override bool SetContent(object content)
private class TooltipDisplayContent
{
public string Name;
public LocalisableString Name;
public string Count;
public string Date;
}

View File

@ -2,17 +2,19 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Profile.Sections.Historical;
using osu.Game.Overlays.Profile.Sections.Ranks;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class HistoricalSection : ProfileSection
{
public override string Title => "Historical";
public override LocalisableString Title => UsersStrings.ShowExtraHistoricalTitle;
public override string Identifier => "historical";
public override string Identifier => @"historical";
public HistoricalSection()
{
@ -20,7 +22,7 @@ public HistoricalSection()
{
new PlayHistorySubsection(User),
new PaginatedMostPlayedBeatmapContainer(User),
new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)"),
new PaginatedScoreContainer(ScoreType.Recent, User, UsersStrings.ShowExtraHistoricalRecentPlaysTitle),
new ReplaysSubsection(User)
};
}

View File

@ -12,6 +12,8 @@
using osu.Game.Graphics.Sprites;
using osu.Game.Users;
using osu.Framework.Allocation;
using osu.Game.Resources.Localisation.Web;
using osu.Framework.Localisation;
namespace osu.Game.Overlays.Profile.Sections.Kudosu
{
@ -37,7 +39,7 @@ public KudosuInfo(Bindable<User> user)
private class CountTotal : CountSection
{
public CountTotal()
: base("Total Kudosu Earned")
: base(UsersStrings.ShowExtraKudosuTotal)
{
DescriptionText.AddText("Based on how much of a contribution the user has made to beatmap moderation. See ");
DescriptionText.AddLink("this page", "https://osu.ppy.sh/wiki/Kudosu");
@ -56,7 +58,7 @@ private class CountSection : Container
set => valueText.Text = value.ToString("N0");
}
public CountSection(string header)
public CountSection(LocalisableString header)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;

View File

@ -8,13 +8,14 @@
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.API;
using System.Collections.Generic;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections.Kudosu
{
public class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection<APIKudosuHistory>
{
public PaginatedKudosuHistoryContainer(Bindable<User> user)
: base(user, missingText: "This user hasn't received any kudosu!")
: base(user, missingText: UsersStrings.ShowExtraKudosuEntryEmpty)
{
ItemsPerPage = 5;
}

View File

@ -3,14 +3,16 @@
using osu.Framework.Graphics;
using osu.Game.Overlays.Profile.Sections.Kudosu;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class KudosuSection : ProfileSection
{
public override string Title => "Kudosu!";
public override LocalisableString Title => UsersStrings.ShowExtraKudosuTitle;
public override string Identifier => "kudosu";
public override string Identifier => @"kudosu";
public KudosuSection()
{

View File

@ -1,12 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class MedalsSection : ProfileSection
{
public override string Title => "Medals";
public override LocalisableString Title => UsersStrings.ShowExtraMedalsTitle;
public override string Identifier => "medals";
public override string Identifier => @"medals";
}
}

View File

@ -15,6 +15,7 @@
using osu.Game.Rulesets;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Framework.Localisation;
namespace osu.Game.Overlays.Profile.Sections
{
@ -36,9 +37,9 @@ public abstract class PaginatedProfileSubsection<TModel> : ProfileSubsection
private ShowMoreButton moreButton;
private OsuSpriteText missing;
private readonly string missingText;
private readonly LocalisableString? missingText;
protected PaginatedProfileSubsection(Bindable<User> user, string headerText = "", string missingText = "")
protected PaginatedProfileSubsection(Bindable<User> user, LocalisableString? headerText = null, LocalisableString? missingText = null)
: base(user, headerText, CounterVisibilityState.AlwaysVisible)
{
this.missingText = missingText;
@ -68,7 +69,7 @@ protected PaginatedProfileSubsection(Bindable<User> user, string headerText = ""
missing = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 15),
Text = missingText,
Text = missingText ?? string.Empty,
Alpha = 0,
}
}
@ -114,7 +115,7 @@ protected virtual void UpdateItems(List<TModel> items) => Schedule(() =>
moreButton.Hide();
moreButton.IsLoading = false;
if (!string.IsNullOrEmpty(missingText))
if (missingText.HasValue)
missing.Show();
return;

View File

@ -7,6 +7,7 @@
using osu.Framework.Graphics.Containers;
using osu.Game.Users;
using JetBrains.Annotations;
using osu.Framework.Localisation;
namespace osu.Game.Overlays.Profile.Sections
{
@ -14,14 +15,14 @@ public abstract class ProfileSubsection : FillFlowContainer
{
protected readonly Bindable<User> User = new Bindable<User>();
private readonly string headerText;
private readonly LocalisableString headerText;
private readonly CounterVisibilityState counterVisibilityState;
private ProfileSubsectionHeader header;
protected ProfileSubsection(Bindable<User> user, string headerText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden)
protected ProfileSubsection(Bindable<User> user, LocalisableString? headerText = null, CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden)
{
this.headerText = headerText;
this.headerText = headerText ?? string.Empty;
this.counterVisibilityState = counterVisibilityState;
User.BindTo(user);
}
@ -37,7 +38,7 @@ private void load()
{
header = new ProfileSubsectionHeader(headerText, counterVisibilityState)
{
Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1
Alpha = string.IsNullOrEmpty(headerText.ToString()) ? 0 : 1
},
CreateContent()
};

View File

@ -11,6 +11,7 @@
using osuTK;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Framework.Localisation;
namespace osu.Game.Overlays.Profile.Sections
{
@ -24,12 +25,12 @@ public Bindable<int> Current
set => current.Current = value;
}
private readonly string text;
private readonly LocalisableString text;
private readonly CounterVisibilityState counterState;
private CounterPill counterPill;
public ProfileSubsectionHeader(string text, CounterVisibilityState counterState)
public ProfileSubsectionHeader(LocalisableString text, CounterVisibilityState counterState)
{
this.text = text;
this.counterState = counterState;

View File

@ -5,6 +5,7 @@
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Scoring;
using osuTK;
@ -51,7 +52,7 @@ public DrawableProfileWeightedScore(ScoreInfo score, double weight)
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 12),
Text = $@"weighted {weight:0%}"
Text = UsersStrings.ShowExtraTopRanksPpWeight(weight.ToString("0%"))
}
}
};

View File

@ -11,6 +11,7 @@
using System.Collections.Generic;
using osu.Game.Online.API;
using osu.Framework.Allocation;
using osu.Framework.Localisation;
namespace osu.Game.Overlays.Profile.Sections.Ranks
{
@ -18,7 +19,7 @@ public class PaginatedScoreContainer : PaginatedProfileSubsection<APILegacyScore
{
private readonly ScoreType type;
public PaginatedScoreContainer(ScoreType type, Bindable<User> user, string headerText)
public PaginatedScoreContainer(ScoreType type, Bindable<User> user, LocalisableString headerText)
: base(user, headerText)
{
this.type = type;

View File

@ -3,21 +3,23 @@
using osu.Game.Overlays.Profile.Sections.Ranks;
using osu.Game.Online.API.Requests;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class RanksSection : ProfileSection
{
public override string Title => "Ranks";
public override LocalisableString Title => UsersStrings.ShowExtraTopRanksTitle;
public override string Identifier => "top_ranks";
public override string Identifier => @"top_ranks";
public RanksSection()
{
Children = new[]
{
new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance"),
new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks")
new PaginatedScoreContainer(ScoreType.Best, User, UsersStrings.ShowExtraTopRanksBestTitle),
new PaginatedScoreContainer(ScoreType.Firsts, User, UsersStrings.ShowExtraTopRanksFirstTitle)
};
}
}

View File

@ -10,13 +10,14 @@
using System.Collections.Generic;
using osuTK;
using osu.Framework.Allocation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections.Recent
{
public class PaginatedRecentActivityContainer : PaginatedProfileSubsection<APIRecentActivity>
{
public PaginatedRecentActivityContainer(Bindable<User> user)
: base(user, missingText: "This user hasn't done anything notable recently!")
: base(user, missingText: EventsStrings.Empty)
{
ItemsPerPage = 10;
}

View File

@ -1,15 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
using osu.Game.Overlays.Profile.Sections.Recent;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Overlays.Profile.Sections
{
public class RecentSection : ProfileSection
{
public override string Title => "Recent";
public override LocalisableString Title => UsersStrings.ShowExtraRecentActivityTitle;
public override string Identifier => "recent_activity";
public override string Identifier => @"recent_activity";
public RecentSection()
{

View File

@ -11,6 +11,7 @@
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -211,7 +212,7 @@ protected abstract class UserGraphTooltip : VisibilityContainer, ITooltip
protected readonly OsuSpriteText Counter, BottomText;
private readonly Box background;
protected UserGraphTooltip(string tooltipCounterName)
protected UserGraphTooltip(LocalisableString tooltipCounterName)
{
AutoSizeAxes = Axes.Both;
Masking = true;

View File

@ -2,11 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Replays;
using osu.Game.Utils;
namespace osu.Game.Replays
{
public class Replay
public class Replay : IDeepCloneable<Replay>
{
/// <summary>
/// Whether all frames for this replay have been received.
@ -15,5 +17,15 @@ public class Replay
public bool HasReceivedAllFrames = true;
public List<ReplayFrame> Frames = new List<ReplayFrame>();
public Replay DeepClone()
{
return new Replay
{
HasReceivedAllFrames = HasReceivedAllFrames,
// individual frames are mutable for now but hopefully this will not be a thing in the future.
Frames = Frames.ToList(),
};
}
}
}

View File

@ -32,7 +32,7 @@ protected DifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap)
/// <returns>A structure describing the difficulty of the beatmap.</returns>
public DifficultyAttributes Calculate(params Mod[] mods)
{
mods = mods.Select(m => m.CreateCopy()).ToArray();
mods = mods.Select(m => m.DeepClone()).ToArray();
IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods);

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods
/// The base class for gameplay modifiers.
/// </summary>
[ExcludeFromDynamicCompile]
public abstract class Mod : IMod, IEquatable<Mod>, IJsonSerializable
public abstract class Mod : IMod, IEquatable<Mod>, IJsonSerializable, IDeepCloneable<Mod>
{
/// <summary>
/// The name of this mod.
@ -132,7 +132,7 @@ public virtual string SettingDescription
/// <summary>
/// Creates a copy of this <see cref="Mod"/> initialised to a default state.
/// </summary>
public virtual Mod CreateCopy()
public virtual Mod DeepClone()
{
var result = (Mod)Activator.CreateInstance(GetType());
result.CopyFrom(this);

View File

@ -20,7 +20,7 @@ public MultiMod(params Mod[] mods)
Mods = mods;
}
public override Mod CreateCopy() => new MultiMod(Mods.Select(m => m.CreateCopy()).ToArray());
public override Mod DeepClone() => new MultiMod(Mods.Select(m => m.DeepClone()).ToArray());
public override Type[] IncompatibleMods => Mods.SelectMany(m => m.IncompatibleMods).ToArray();
}

View File

@ -502,8 +502,7 @@ protected void UpdateComboColour()
{
if (!(HitObject is IHasComboInformation combo)) return;
var comboColours = CurrentSkin.GetConfig<GlobalSkinColours, IReadOnlyList<Color4>>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty<Color4>();
AccentColour.Value = combo.GetComboColour(comboColours);
AccentColour.Value = combo.GetComboColour(CurrentSkin);
}
/// <summary>

View File

@ -1,9 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Objects.Types
@ -40,11 +39,21 @@ public interface IHasComboInformation : IHasCombo
bool LastInCombo { get; set; }
/// <summary>
/// Retrieves the colour of the combo described by this <see cref="IHasComboInformation"/> object from a set of possible combo colours.
/// Defaults to using <see cref="ComboIndex"/> to decide the colour.
/// Retrieves the colour of the combo described by this <see cref="IHasComboInformation"/> object.
/// </summary>
/// <param name="comboColours">A list of possible combo colours provided by the beatmap or skin.</param>
/// <returns>The colour of the combo described by this <see cref="IHasComboInformation"/> object.</returns>
Color4 GetComboColour([NotNull] IReadOnlyList<Color4> comboColours) => comboColours.Count > 0 ? comboColours[ComboIndex % comboColours.Count] : Color4.White;
/// <param name="skin">The skin to retrieve the combo colour from, if wanted.</param>
Color4 GetComboColour(ISkin skin) => GetSkinComboColour(this, skin, ComboIndex);
/// <summary>
/// Retrieves the colour of the combo described by a given <see cref="IHasComboInformation"/> object from a given skin.
/// </summary>
/// <param name="combo">The combo information, should be <c>this</c>.</param>
/// <param name="skin">The skin to retrieve the combo colour from.</param>
/// <param name="comboIndex">The index to retrieve the combo colour with.</param>
/// <returns></returns>
protected static Color4 GetSkinComboColour(IHasComboInformation combo, ISkin skin, int comboIndex)
{
return skin.GetConfig<SkinComboColourLookup, Color4>(new SkinComboColourLookup(comboIndex, combo))?.Value ?? Color4.White;
}
}
}

View File

@ -338,7 +338,6 @@ public virtual void PopulateScore(ScoreInfo score)
score.MaxCombo = HighestCombo.Value;
score.Accuracy = Accuracy.Value;
score.Rank = Rank.Value;
score.Date = DateTimeOffset.Now;
foreach (var result in Enum.GetValues(typeof(HitResult)).OfType<HitResult>().Where(r => r.IsScorable()))
score.Statistics[result] = GetStatistic(result);

View File

@ -2,12 +2,22 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Replays;
using osu.Game.Utils;
namespace osu.Game.Scoring
{
public class Score
public class Score : IDeepCloneable<Score>
{
public ScoreInfo ScoreInfo = new ScoreInfo();
public Replay Replay = new Replay();
public Score DeepClone()
{
return new Score
{
ScoreInfo = ScoreInfo.DeepClone(),
Replay = Replay.DeepClone(),
};
}
}
}

View File

@ -18,7 +18,7 @@
namespace osu.Game.Scoring
{
public class ScoreInfo : IHasFiles<ScoreFileInfo>, IHasPrimaryKey, ISoftDelete, IEquatable<ScoreInfo>
public class ScoreInfo : IHasFiles<ScoreFileInfo>, IHasPrimaryKey, ISoftDelete, IEquatable<ScoreInfo>, IDeepCloneable<ScoreInfo>
{
public int ID { get; set; }
@ -242,6 +242,15 @@ public IEnumerable<HitResultDisplayStatistic> GetStatisticsForDisplay()
}
}
public ScoreInfo DeepClone()
{
var clone = (ScoreInfo)MemberwiseClone();
clone.Statistics = new Dictionary<HitResult, int>(clone.Statistics);
return clone;
}
public override string ToString() => $"{User} playing {Beatmap}";
public bool Equals(ScoreInfo other)

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -153,11 +152,8 @@ private void updateColour()
break;
case IHasComboInformation combo:
{
var comboColours = skin.GetConfig<GlobalSkinColours, IReadOnlyList<Color4>>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty<Color4>();
colour = combo.GetComboColour(comboColours);
colour = combo.GetComboColour(skin);
break;
}
default:
return;

View File

@ -128,7 +128,7 @@ private void load(OsuColour colours, OsuConfigManager config)
// clone these locally for now to avoid incurring overhead on GetPlayableBeatmap usages.
// eventually we will want to improve how/where this is done as there are issues with *not* cloning it in all cases.
playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.CreateCopy();
playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.DeepClone();
}
catch (Exception e)
{

View File

@ -72,8 +72,6 @@ public bool Triangles
set => colourAndTriangles.FadeTo(value ? 1 : 0, transition_length, Easing.OutQuint);
}
public bool BeatMatching = true;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => logoContainer.ReceivePositionalInputAt(screenSpacePos);
public bool Ripple
@ -272,8 +270,6 @@ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint,
{
base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);
if (!BeatMatching) return;
lastBeatIndex = beatIndex;
var beatLength = timingPoint.BeatLength;

View File

@ -84,10 +84,10 @@ public virtual void CreateRoom(Room room, Action<Room> onSuccess = null, Action<
private JoinRoomRequest currentJoinRoomRequest;
public virtual void JoinRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null)
public virtual void JoinRoom(Room room, string password = null, Action<Room> onSuccess = null, Action<string> onError = null)
{
currentJoinRoomRequest?.Cancel();
currentJoinRoomRequest = new JoinRoomRequest(room);
currentJoinRoomRequest = new JoinRoomRequest(room, password);
currentJoinRoomRequest.Success += () =>
{

View File

@ -6,6 +6,8 @@
using osu.Framework.Bindables;
using osu.Game.Online.Rooms;
#nullable enable
namespace osu.Game.Screens.OnlinePlay
{
[Cached(typeof(IRoomManager))]
@ -32,15 +34,16 @@ public interface IRoomManager
/// <param name="room">The <see cref="Room"/> to create.</param>
/// <param name="onSuccess">An action to be invoked if the creation succeeds.</param>
/// <param name="onError">An action to be invoked if an error occurred.</param>
void CreateRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null);
void CreateRoom(Room room, Action<Room>? onSuccess = null, Action<string>? onError = null);
/// <summary>
/// Joins a <see cref="Room"/>.
/// </summary>
/// <param name="room">The <see cref="Room"/> to join. <see cref="Room.RoomID"/> must be populated.</param>
/// <param name="password">An optional password to use for the join operation.</param>
/// <param name="onSuccess"></param>
/// <param name="onError"></param>
void JoinRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null);
void JoinRoom(Room room, string? password = null, Action<Room>? onSuccess = null, Action<string>? onError = null);
/// <summary>
/// Parts the currently-joined <see cref="Room"/>.

View File

@ -6,19 +6,25 @@
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Input.Bindings;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Components;
using osuTK;
@ -26,7 +32,7 @@
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public class DrawableRoom : OsuClickableContainer, IStateful<SelectionState>, IFilterable, IHasContextMenu
public class DrawableRoom : OsuClickableContainer, IStateful<SelectionState>, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler<GlobalAction>
{
public const float SELECTION_BORDER_WIDTH = 4;
private const float corner_radius = 5;
@ -46,6 +52,12 @@ public class DrawableRoom : OsuClickableContainer, IStateful<SelectionState>, IF
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved(canBeNull: true)]
private Bindable<Room> selectedRoom { get; set; }
[Resolved(canBeNull: true)]
private LoungeSubScreen lounge { get; set; }
public readonly Room Room;
private SelectionState state;
@ -91,6 +103,10 @@ public bool MatchingFilter
public bool FilteringActive { get; set; }
private PasswordProtectedIcon passwordIcon;
private readonly Bindable<bool> hasPassword = new Bindable<bool>();
public DrawableRoom(Room room)
{
Room = room;
@ -200,6 +216,7 @@ private void load(OsuColour colours)
},
},
},
passwordIcon = new PasswordProtectedIcon { Alpha = 0 }
},
},
},
@ -222,10 +239,69 @@ protected override void LoadComplete()
this.FadeInFromZero(transition_duration);
else
Alpha = 0;
hasPassword.BindTo(Room.HasPassword);
hasPassword.BindValueChanged(v => passwordIcon.Alpha = v.NewValue ? 1 : 0, true);
}
public Popover GetPopover() => new PasswordEntryPopover(Room) { JoinRequested = lounge.Join };
public MenuItem[] ContextMenuItems => new MenuItem[]
{
new OsuMenuItem("Create copy", MenuItemType.Standard, () =>
{
parentScreen?.OpenNewRoom(Room.DeepClone());
})
};
public bool OnPressed(GlobalAction action)
{
if (selectedRoom.Value != Room)
return false;
switch (action)
{
case GlobalAction.Select:
Click();
return true;
}
return false;
}
public void OnReleased(GlobalAction action)
{
}
protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected;
protected override bool OnMouseDown(MouseDownEvent e)
{
if (selectedRoom.Value != Room)
return true;
return base.OnMouseDown(e);
}
protected override bool OnClick(ClickEvent e)
{
if (Room != selectedRoom.Value)
{
selectedRoom.Value = Room;
return true;
}
if (Room.HasPassword.Value)
{
this.ShowPopover();
return true;
}
lounge?.Join(Room, null);
return base.OnClick(e);
}
private class RoomName : OsuSpriteText
{
[Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))]
@ -238,12 +314,84 @@ private void load()
}
}
public MenuItem[] ContextMenuItems => new MenuItem[]
public class PasswordProtectedIcon : CompositeDrawable
{
new OsuMenuItem("Create copy", MenuItemType.Standard, () =>
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
parentScreen?.OpenNewRoom(Room.CreateCopy());
})
};
Anchor = Anchor.TopRight;
Origin = Anchor.TopRight;
Size = new Vector2(32);
InternalChildren = new Drawable[]
{
new Box
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopCentre,
Colour = colours.Gray5,
Rotation = 45,
RelativeSizeAxes = Axes.Both,
Width = 2,
},
new SpriteIcon
{
Icon = FontAwesome.Solid.Lock,
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Margin = new MarginPadding(6),
Size = new Vector2(14),
}
};
}
}
public class PasswordEntryPopover : OsuPopover
{
private readonly Room room;
public Action<Room, string> JoinRequested;
public PasswordEntryPopover(Room room)
{
this.room = room;
}
private OsuPasswordTextBox passwordTextbox;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Child = new FillFlowContainer
{
Margin = new MarginPadding(10),
Spacing = new Vector2(5),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
passwordTextbox = new OsuPasswordTextBox
{
Width = 200,
},
new TriangleButton
{
Width = 80,
Text = "Join Room",
Action = () => JoinRequested?.Invoke(room, passwordTextbox.Text)
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox));
passwordTextbox.OnCommit += (_, __) => JoinRequested?.Invoke(room, passwordTextbox.Text);
}
}
}
}

View File

@ -24,8 +24,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public class RoomsContainer : CompositeDrawable, IKeyBindingHandler<GlobalAction>
{
public Action<Room> JoinRequested;
private readonly IBindableList<Room> rooms = new BindableList<Room>();
private readonly FillFlowContainer<DrawableRoom> roomFlow;
@ -121,19 +119,7 @@ private void addRooms(IEnumerable<Room> rooms)
{
foreach (var room in rooms)
{
roomFlow.Add(new DrawableRoom(room)
{
Action = () =>
{
if (room == selectedRoom.Value)
{
joinSelected();
return;
}
selectRoom(room);
}
});
roomFlow.Add(new DrawableRoom(room));
}
Filter(filter?.Value);
@ -150,7 +136,7 @@ private void removeRooms(IEnumerable<Room> rooms)
roomFlow.Remove(toRemove);
selectRoom(null);
selectedRoom.Value = null;
}
}
@ -160,18 +146,9 @@ private void updateSorting()
roomFlow.SetLayoutPosition(room, room.Room.Position.Value);
}
private void selectRoom(Room room) => selectedRoom.Value = room;
private void joinSelected()
{
if (selectedRoom.Value == null) return;
JoinRequested?.Invoke(selectedRoom.Value);
}
protected override bool OnClick(ClickEvent e)
{
selectRoom(null);
selectedRoom.Value = null;
return base.OnClick(e);
}
@ -181,10 +158,6 @@ public bool OnPressed(GlobalAction action)
{
switch (action)
{
case GlobalAction.Select:
joinSelected();
return true;
case GlobalAction.SelectNext:
beginRepeatSelection(() => selectNext(1), action);
return true;
@ -253,7 +226,7 @@ private void selectNext(int direction)
// we already have a valid selection only change selection if we still have a room to switch to.
if (room != null)
selectRoom(room);
selectedRoom.Value = room;
}
#endregion

View File

@ -6,6 +6,7 @@
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
@ -46,10 +47,11 @@ public abstract class LoungeSubScreen : OnlinePlaySubScreen
[CanBeNull]
private IDisposable joiningRoomOperation { get; set; }
private RoomsContainer roomsContainer;
[BackgroundDependencyLoader]
private void load()
{
RoomsContainer roomsContainer;
OsuScrollContainer scrollContainer;
InternalChildren = new Drawable[]
@ -70,7 +72,7 @@ private void load()
RelativeSizeAxes = Axes.Both,
ScrollbarOverlapsContent = false,
Padding = new MarginPadding(10),
Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested }
Child = roomsContainer = new RoomsContainer()
},
loadingLayer = new LoadingLayer(true),
}
@ -150,31 +152,39 @@ public override void OnResuming(IScreen last)
onReturning();
}
private void onReturning()
{
filter.HoldFocus = true;
}
public override bool OnExiting(IScreen next)
{
filter.HoldFocus = false;
onLeaving();
return base.OnExiting(next);
}
public override void OnSuspending(IScreen next)
{
onLeaving();
base.OnSuspending(next);
filter.HoldFocus = false;
}
private void joinRequested(Room room)
private void onReturning()
{
filter.HoldFocus = true;
}
private void onLeaving()
{
filter.HoldFocus = false;
// ensure any password prompt is dismissed.
this.HidePopover();
}
public void Join(Room room, string password)
{
if (joiningRoomOperation != null)
return;
joiningRoomOperation = ongoingOperationTracker?.BeginOperation();
RoomManager?.JoinRoom(room, r =>
RoomManager?.JoinRoom(room, password, r =>
{
Open(room);
joiningRoomOperation?.Dispose();

View File

@ -25,8 +25,12 @@ public abstract class MatchSettingsOverlay : FocusedOverlayContainer
private void load()
{
Masking = true;
Add(Settings = CreateSettings());
}
protected abstract OnlinePlayComposite CreateSettings();
protected override void PopIn()
{
Settings.MoveToY(0, TRANSITION_DURATION, Easing.OutQuint);

View File

@ -27,16 +27,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
public class MultiplayerMatchSettingsOverlay : MatchSettingsOverlay
{
[BackgroundDependencyLoader]
private void load()
{
Child = Settings = new MatchSettings
protected override OnlinePlayComposite CreateSettings()
=> new MatchSettings
{
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Y,
SettingsApplied = Hide
};
}
protected class MatchSettings : OnlinePlayComposite
{
@ -47,6 +44,7 @@ protected class MatchSettings : OnlinePlayComposite
public OsuTextBox NameField, MaxParticipantsField;
public RoomAvailabilityPicker AvailabilityPicker;
public GameTypePicker TypePicker;
public OsuTextBox PasswordTextBox;
public TriangleButton ApplyButton;
public OsuSpriteText ErrorText;
@ -193,12 +191,10 @@ private void load(OsuColour colours)
},
new Section("Password (optional)")
{
Alpha = disabled_alpha,
Child = new SettingsPasswordTextBox
Child = PasswordTextBox = new SettingsPasswordTextBox
{
RelativeSizeAxes = Axes.X,
TabbableContentContainer = this,
ReadOnly = true,
},
},
}
@ -275,6 +271,7 @@ private void load(OsuColour colours)
Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true);
MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true);
RoomID.BindValueChanged(roomId => initialBeatmapControl.Alpha = roomId.NewValue == null ? 1 : 0, true);
Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true);
operationInProgress.BindTo(ongoingOperationTracker.InProgress);
operationInProgress.BindValueChanged(v =>
@ -307,7 +304,7 @@ private void apply()
// Otherwise, update the room directly in preparation for it to be submitted to the API on match creation.
if (client.Room != null)
{
client.ChangeSettings(name: NameField.Text).ContinueWith(t => Schedule(() =>
client.ChangeSettings(name: NameField.Text, password: PasswordTextBox.Text).ContinueWith(t => Schedule(() =>
{
if (t.IsCompletedSuccessfully)
onSuccess(currentRoom.Value);
@ -320,6 +317,7 @@ private void apply()
currentRoom.Value.Name.Value = NameField.Text;
currentRoom.Value.Availability.Value = AvailabilityPicker.Current.Value;
currentRoom.Value.Type.Value = TypePicker.Current.Value;
currentRoom.Value.Password.Value = PasswordTextBox.Current.Value;
if (int.TryParse(MaxParticipantsField.Text, out int max))
currentRoom.Value.MaxParticipants.Value = max;

View File

@ -38,9 +38,9 @@ protected override void LoadComplete()
}
public override void CreateRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null)
=> base.CreateRoom(room, r => joinMultiplayerRoom(r, onSuccess, onError), onError);
=> base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password.Value, onSuccess, onError), onError);
public override void JoinRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null)
public override void JoinRoom(Room room, string password = null, Action<Room> onSuccess = null, Action<string> onError = null)
{
if (!multiplayerClient.IsConnected.Value)
{
@ -56,7 +56,7 @@ public override void JoinRoom(Room room, Action<Room> onSuccess = null, Action<s
return;
}
base.JoinRoom(room, r => joinMultiplayerRoom(r, onSuccess, onError), onError);
base.JoinRoom(room, password, r => joinMultiplayerRoom(r, password, onSuccess, onError), onError);
}
public override void PartRoom()
@ -79,11 +79,11 @@ public override void PartRoom()
});
}
private void joinMultiplayerRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null)
private void joinMultiplayerRoom(Room room, string password, Action<Room> onSuccess = null, Action<string> onError = null)
{
Debug.Assert(room.RoomID.Value != null);
multiplayerClient.JoinRoom(room).ContinueWith(t =>
multiplayerClient.JoinRoom(room, password).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
Schedule(() => onSuccess?.Invoke(room));

View File

@ -56,6 +56,9 @@ public class OnlinePlayComposite : CompositeDrawable
[Resolved(typeof(Room))]
protected Bindable<RoomAvailability> Availability { get; private set; }
[Resolved(typeof(Room), nameof(Room.Password))]
public Bindable<string> Password { get; private set; }
[Resolved(typeof(Room))]
protected Bindable<TimeSpan?> Duration { get; private set; }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -30,17 +31,19 @@ namespace osu.Game.Screens.OnlinePlay
[Cached]
public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack
{
public override bool CursorVisible => (screenStack.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true;
public override bool CursorVisible => (screenStack?.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true;
// this is required due to PlayerLoader eventually being pushed to the main stack
// while leases may be taken out by a subscreen.
public override bool DisallowExternalBeatmapRulesetChanges => true;
private readonly MultiplayerWaveContainer waves;
private MultiplayerWaveContainer waves;
private readonly OsuButton createButton;
private readonly LoungeSubScreen loungeSubScreen;
private readonly ScreenStack screenStack;
private OsuButton createButton;
private ScreenStack screenStack;
private LoungeSubScreen loungeSubScreen;
private readonly IBindable<bool> isIdle = new BindableBool();
@ -54,7 +57,7 @@ public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack
private readonly Bindable<FilterCriteria> currentFilter = new Bindable<FilterCriteria>(new FilterCriteria());
[Cached]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker();
[Resolved(CanBeNull = true)]
private MusicController music { get; set; }
@ -65,11 +68,14 @@ public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack
[Resolved]
protected IAPIProvider API { get; private set; }
[Resolved(CanBeNull = true)]
private IdleTracker idleTracker { get; set; }
[Resolved(CanBeNull = true)]
private OsuLogo logo { get; set; }
private readonly Drawable header;
private readonly Drawable headerBackground;
private Drawable header;
private Drawable headerBackground;
protected OnlinePlayScreen()
{
@ -78,6 +84,14 @@ protected OnlinePlayScreen()
RelativeSizeAxes = Axes.Both;
Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING };
RoomManager = CreateRoomManager();
}
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
[BackgroundDependencyLoader]
private void load()
{
var backgroundColour = Color4Extensions.FromHex(@"3e3a44");
InternalChild = waves = new MultiplayerWaveContainer
@ -144,27 +158,14 @@ protected OnlinePlayScreen()
};
button.Action = () => OpenNewRoom();
}),
RoomManager = CreateRoomManager(),
ongoingOperationTracker = new OngoingOperationTracker()
RoomManager,
ongoingOperationTracker,
}
};
screenStack.ScreenPushed += screenPushed;
screenStack.ScreenExited += screenExited;
screenStack.Push(loungeSubScreen = CreateLounge());
}
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
[BackgroundDependencyLoader(true)]
private void load(IdleTracker idleTracker)
{
apiState.BindTo(API.State);
apiState.BindValueChanged(onlineStateChanged, true);
if (idleTracker != null)
isIdle.BindTo(idleTracker.IsIdle);
// a lot of the functionality in this class depends on loungeSubScreen being in a ready to go state.
// as such, we intentionally load this inline so it is ready alongside this screen.
LoadComponent(loungeSubScreen = CreateLounge());
}
private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() =>
@ -179,7 +180,20 @@ private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule((
protected override void LoadComplete()
{
base.LoadComplete();
isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true);
screenStack.ScreenPushed += screenPushed;
screenStack.ScreenExited += screenExited;
screenStack.Push(loungeSubScreen);
apiState.BindTo(API.State);
apiState.BindValueChanged(onlineStateChanged, true);
if (idleTracker != null)
{
isIdle.BindTo(idleTracker.IsIdle);
isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true);
}
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
@ -222,7 +236,9 @@ public override void OnResuming(IScreen last)
this.FadeIn(250);
this.ScaleTo(1, 250, Easing.OutSine);
screenStack.CurrentScreen?.OnResuming(last);
Debug.Assert(screenStack.CurrentScreen != null);
screenStack.CurrentScreen.OnResuming(last);
base.OnResuming(last);
UpdatePollingRate(isIdle.Value);
@ -233,14 +249,16 @@ public override void OnSuspending(IScreen next)
this.ScaleTo(1.1f, 250, Easing.InSine);
this.FadeOut(250);
screenStack.CurrentScreen?.OnSuspending(next);
Debug.Assert(screenStack.CurrentScreen != null);
screenStack.CurrentScreen.OnSuspending(next);
UpdatePollingRate(isIdle.Value);
}
public override bool OnExiting(IScreen next)
{
if (screenStack.CurrentScreen?.OnExiting(next) == true)
var subScreen = screenStack.CurrentScreen as Drawable;
if (subScreen?.IsLoaded == true && screenStack.CurrentScreen.OnExiting(next))
return true;
RoomManager.PartRoom();

View File

@ -72,8 +72,8 @@ protected override void LoadComplete()
// At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods.
// Similarly, freeMods is currently empty but should only contain the allowed mods.
Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty<Mod>();
FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty<Mod>();
Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty<Mod>();
FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty<Mod>();
Mods.BindValueChanged(onModsChanged);
Ruleset.BindValueChanged(onRulesetChanged);
@ -108,8 +108,8 @@ protected sealed override bool OnStart()
}
};
item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy()));
item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy()));
item.RequiredMods.AddRange(Mods.Value.Select(m => m.DeepClone()));
item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.DeepClone()));
SelectItem(item);
return true;

View File

@ -26,16 +26,13 @@ public class PlaylistsMatchSettingsOverlay : MatchSettingsOverlay
{
public Action EditPlaylist;
[BackgroundDependencyLoader]
private void load()
{
Child = Settings = new MatchSettings
protected override OnlinePlayComposite CreateSettings()
=> new MatchSettings
{
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Y,
EditPlaylist = () => EditPlaylist?.Invoke()
};
}
protected class MatchSettings : OnlinePlayComposite
{

View File

@ -55,10 +55,10 @@ private void populateItemFromCurrent(PlaylistItem item)
item.Ruleset.Value = Ruleset.Value;
item.RequiredMods.Clear();
item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy()));
item.RequiredMods.AddRange(Mods.Value.Select(m => m.DeepClone()));
item.AllowedMods.Clear();
item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy()));
item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.DeepClone()));
}
}
}

Some files were not shown because too many files have changed in this diff Show More