1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-14 17:02:53 +08:00

Compare commits

...

153 Commits

148 changed files with 8193 additions and 351 deletions
+5
View File
@@ -19,6 +19,11 @@ indent_style = space
indent_size = 4
trim_trailing_whitespace = true
# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130051/Cannot-resolve-symbol-inspections-incorrectly-firing-for-xmldoc-protected-member-references
resharper_c_sharp_warnings_cs1574_cs1584_cs1581_cs1580_highlighting = hint
# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130381/Rider-does-not-respect-propagated-NoWarn-CS1591?backToIssues=false
dotnet_diagnostic.CS1591.severity = none
#license header
file_header_template = Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text.
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.908.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.930.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private Drawable noteAnimation = null!;
private float? minimumColumnWidth;
private float? widthForNoteHeightScale;
public LegacyNotePiece()
{
@@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
[BackgroundDependencyLoader]
private void load(ISkinSource skin, IScrollingInfo scrollingInfo)
{
minimumColumnWidth = skin.GetConfig<ManiaSkinConfigurationLookup, float>(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.MinimumColumnWidth))?.Value;
widthForNoteHeightScale = skin.GetConfig<ManiaSkinConfigurationLookup, float>(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.WidthForNoteHeightScale))?.Value;
InternalChild = directionContainer = new Container
{
@@ -60,9 +60,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (texture != null)
{
// The height is scaled to the minimum column width, if provided.
float minimumWidth = minimumColumnWidth ?? DrawWidth;
noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), texture.DisplayWidth);
float noteHeight = widthForNoteHeightScale ?? DrawWidth;
noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, noteHeight), texture.DisplayWidth);
}
}
@@ -191,6 +191,7 @@ namespace osu.Game.Rulesets.Osu.Tests
}
[Test]
[FlakyTest]
public void TestRewind()
{
AddStep("set manual clock", () => manualClock = new ManualClock
@@ -469,9 +469,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
else
{
SnapResult result = positionSnapProvider?.TrySnapToPositionGrid(Parent!.ToScreenSpace(e.MousePosition));
Vector2 newControlPointPosition = Parent!.ToScreenSpace(e.MousePosition);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
// Snapping inherited B-spline control points to nearby objects would be unintuitive, because snapping them does not equate to snapping the interpolated slider path.
bool shouldSnapToNearbyObjects = dragPathTypes[draggedControlPointIndex] is not null ||
dragPathTypes[..draggedControlPointIndex].LastOrDefault(t => t is not null)?.Type != SplineType.BSpline;
SnapResult result = null;
if (shouldSnapToNearbyObjects)
result = positionSnapProvider?.TrySnapToNearbyObjects(newControlPointPosition, oldStartTime);
if (positionSnapProvider?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? newControlPointPosition, result?.Time ?? oldStartTime) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(newControlPointPosition, oldStartTime);
Vector2 movementDelta = Parent!.ToLocalSpace(result.ScreenSpacePosition) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
for (int i = 0; i < controlPoints.Count; ++i)
{
@@ -626,10 +626,38 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset)
?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation);
protected override Vector2[] ScreenSpaceAdditionalNodes => new[]
{
protected override Vector2[] ScreenSpaceAdditionalNodes => getScreenSpaceControlPointNodes().Prepend(
DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation)
};
).ToArray();
private IEnumerable<Vector2> getScreenSpaceControlPointNodes()
{
// Returns the positions of control points that produce visible kinks on the slider's path
// This excludes inherited control points from Bezier, B-Spline, Perfect, and Catmull curves
if (DrawableObject.SliderBody == null)
yield break;
PathType? currentPathType = null;
// Skip the last control point because its always either not on the slider path or exactly on the slider end
for (int i = 0; i < DrawableObject.HitObject.Path.ControlPoints.Count - 1; i++)
{
var controlPoint = DrawableObject.HitObject.Path.ControlPoints[i];
if (controlPoint.Type is not null)
currentPathType = controlPoint.Type;
// Skip the first control point because it is already covered by the slider head
if (i == 0)
continue;
if (controlPoint.Type is null && currentPathType != PathType.LINEAR)
continue;
var screenSpacePosition = DrawableObject.SliderBody.ToScreenSpace(DrawableObject.SliderBody.PathOffset + controlPoint.Position);
yield return screenSpacePosition;
}
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
@@ -253,7 +253,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects);
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects, true);
Vector2 delta = Vector2.Zero;
@@ -81,12 +81,8 @@ namespace osu.Game.Rulesets.Osu.Edit
changeHandler?.BeginChange();
objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho));
OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position))
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2
? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position))
: GeometryUtils.GetConvexHull(objectsInScale.Keys);
OriginalSurroundingQuad = GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
originalConvexHull = GeometryUtils.GetConvexHull(objectsInScale.Keys);
defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1;
}
@@ -312,7 +308,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void moveSelectionInBounds()
{
Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys);
Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys, true);
Vector2 delta = Vector2.Zero;
@@ -46,9 +46,12 @@ namespace osu.Game.Tests.Extensions
[Test]
[SetCulture("fr-FR")]
public void TestCultureInsensitivity()
[TestCase(0.4, true, 2, ExpectedResult = "40%")]
[TestCase(1e-6, false, 6, ExpectedResult = "0.000001")]
[TestCase(0.48333, true, 4, ExpectedResult = "48.33%")]
public string TestCultureInsensitivity(double input, bool percent, int decimalDigits)
{
Assert.That(0.4.ToStandardFormattedString(maxDecimalDigits: 2, asPercentage: true), Is.EqualTo("40%"));
return input.ToStandardFormattedString(decimalDigits, percent);
}
}
}
@@ -68,6 +68,25 @@ namespace osu.Game.Tests.Visual.Editing
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f));
}, 0, true);
});
AddStep("increase progress slowly then fail", () =>
{
incrementingProgress = 0;
ScheduledDelegate? task = null;
task = Scheduler.AddDelayed(() =>
{
if (incrementingProgress >= 1)
{
progress.SetFailed("nope");
// ReSharper disable once AccessToModifiedClosure
task?.Cancel();
return;
}
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.001f));
}, 0, true);
});
AddUntilStep("wait for completed", () => incrementingProgress >= 1);
AddStep("completed", () => progress.SetCompleted());
@@ -183,6 +183,9 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All);
AddWaitStep("wait some", 2);
AddAssert("Check all visible", () => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Last().Alpha == 1);
AddToggleStep("toggle wireframe display", t => counterDisplay.WireframeOpacity.Value = t ? 0.3f : 0);
AddStep("Set direction vertical", () => counterDisplay.FlowDirection.Value = Direction.Vertical);
AddStep("Set direction horizontal", () => counterDisplay.FlowDirection.Value = Direction.Horizontal);
}
private int hiddenCount()
@@ -0,0 +1,210 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.OnlinePlay;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectGrid : OnlinePlayTestScene
{
private MultiplayerPlaylistItem[] items = null!;
private BeatmapSelectGrid grid = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
var beatmaps = beatmapManager.GetAllUsableBeatmapSets()
.SelectMany(it => it.Beatmaps)
.Take(50)
.ToArray();
if (beatmaps.Length > 0)
{
items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = beatmaps[i % beatmaps.Length].OnlineID,
StarRating = i / 10.0,
}).ToArray();
}
else
{
items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
}
}
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add grid", () => Child = grid = new BeatmapSelectGrid
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(0.8f),
});
AddStep("add items", () =>
{
foreach (var item in items)
grid.AddItem(item);
});
AddWaitStep("wait for panels", 3);
}
[Test]
public void TestBasic()
{
AddStep("do nothing", () =>
{
// test scene is weird.
});
AddStep("add selection 1", () => grid.ChildrenOfType<BeatmapSelectPanel>().First().AddUser(new APIUser
{
Id = 6411631,
Username = "Maarvin",
}, isOwnUser: true));
AddStep("add selection 2", () => grid.ChildrenOfType<BeatmapSelectPanel>().Skip(5).First().AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}));
AddStep("add selection 3", () => grid.ChildrenOfType<BeatmapSelectPanel>().Skip(10).First().AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}));
}
[Test]
public void TestCompleteRollAnimation()
{
AddStep("play animation", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.RollAndDisplayFinalBeatmap(candidateItems, finalItem);
});
}
[Test]
public void TestRollAnimation()
{
AddStep("play animation", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0);
grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0);
Scheduler.AddDelayed(() => grid.PlayRollAnimation(finalItem), 500);
});
}
[Test]
public void TestPresentRolledBeatmap()
{
AddStep("present beatmap", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0);
grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0);
grid.PlayRollAnimation(finalItem, duration: 0);
Scheduler.AddDelayed(() => grid.PresentRolledBeatmap(finalItem), 500);
});
}
[Test]
public void TestPresentUnanimouslyChosenBeatmap()
{
AddStep("present beatmap", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0);
grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0);
grid.PlayRollAnimation(finalItem, duration: 0);
Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(finalItem), 500);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
[TestCase(4)]
[TestCase(5)]
[TestCase(6)]
[TestCase(7)]
[TestCase(8)]
public void TestPanelArrangement(int count)
{
AddStep("arrange panels", () =>
{
var (candidateItems, _) = pickRandomItems(count);
grid.TransferCandidatePanelsToRollContainer(candidateItems);
grid.Delay(BeatmapSelectGrid.ARRANGE_DELAY)
.Schedule(() => grid.ArrangeItemsForRollAnimation());
});
AddWaitStep("wait for movement", 5);
AddStep("display roll order", () =>
{
var panels = grid.ChildrenOfType<BeatmapSelectPanel>().ToArray();
for (int i = 0; i < panels.Length; i++)
{
var panel = panels[i];
panel.Add(new OsuSpriteText
{
Text = (i + 1).ToString(),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(size: 50, weight: FontWeight.SemiBold),
});
}
});
}
private (long[] candidateItems, long finalItem) pickRandomItems(int count)
{
long[] candidateItems = items.Select(it => it.ID).ToArray();
Random.Shared.Shuffle(candidateItems);
candidateItems = candidateItems.Take(count).ToArray();
long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)];
return (candidateItems, finalItem);
}
}
}
@@ -0,0 +1,57 @@
// 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.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectPanel : MultiplayerTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
[Test]
public void TestBeatmapPanel()
{
BeatmapSelectPanel? panel = null;
AddStep("add panel", () => Child = panel = new BeatmapSelectPanel(new MultiplayerPlaylistItem())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
AddStep("add maarvin", () => panel!.AddUser(new APIUser
{
Id = 6411631,
Username = "Maarvin",
}, isOwnUser: true));
AddStep("add peppy", () => panel!.AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}));
AddStep("add smogipoo", () => panel!.AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}));
AddStep("remove smogipoo", () => panel!.RemoveUser(new APIUser { Id = 1040328 }));
AddStep("remove peppy", () => panel!.RemoveUser(new APIUser { Id = 2 }));
AddStep("remove maarvin", () => panel!.RemoveUser(new APIUser { Id = 6411631 }));
AddToggleStep("allow selection", value =>
{
if (panel != null)
panel.AllowSelection = value;
});
}
}
}
@@ -0,0 +1,90 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundWarmup;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneIdleScreen : MultiplayerTestScene
{
private const int user_count = 8;
private (MultiplayerRoomUser user, int score)[] userScores = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add list", () =>
{
userScores = Enumerable.Range(1, user_count).Select(i =>
{
var user = new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"Player {i}"
}
};
return (user, 0);
}).ToArray();
Child = new ScreenStack(new SubScreenRoundWarmup())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(0.8f)
};
});
AddStep("join users", () =>
{
foreach (var (user, _) in userScores)
MultiplayerClient.AddUser(user);
});
}
[Test]
public void TestRandomChanges()
{
AddStep("apply random changes", () =>
{
int[] deltas = Enumerable.Range(1, userScores.Length).ToArray();
new Random().Shuffle(deltas);
for (int i = 0; i < userScores.Length; i++)
userScores[i] = (userScores[i].user, userScores[i].score + deltas[i]);
userScores = userScores.OrderByDescending(u => u.score).ToArray();
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Users =
{
UserDictionary = userScores.Select((tuple, i) => new MatchmakingUser
{
UserId = tuple.user.UserID,
Points = tuple.score,
Placement = i + 1
}).ToDictionary(s => s.UserId)
}
}).WaitSafely();
});
}
}
}
@@ -0,0 +1,44 @@
// 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;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingCloud : OsuTestScene
{
private CloudVisualisation cloud = null!;
protected override void LoadComplete()
{
base.LoadComplete();
Child = cloud = new CloudVisualisation
{
RelativeSizeAxes = Axes.Both,
};
}
[Test]
public void TestBasic()
{
AddStep("refresh users", () =>
{
var testUsers = Enumerable.Range(0, 50).Select(_ => new APIUser
{
Username = "peppy",
Statistics = new UserStatistics { GlobalRank = 1234 },
Id = RNG.Next(2, 30000000),
}).ToArray();
cloud.Users = testUsers;
});
}
}
}
@@ -0,0 +1,35 @@
// 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.Graphics;
using osu.Game.Online.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingPoolSelector : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add selector", () => Child = new PoolSelector
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AvailablePools =
{
Value =
[
new MatchmakingPool { Id = 0, RulesetId = 0 },
new MatchmakingPool { Id = 1, RulesetId = 1 },
new MatchmakingPool { Id = 2, RulesetId = 2 },
new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4 },
new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7 },
]
}
});
}
}
}
@@ -0,0 +1,58 @@
// 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.Allocation;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.Intro;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingQueueScreen : MultiplayerTestScene
{
[Cached]
private readonly QueueController controller = new QueueController();
private ScreenQueue? queueScreen => Stack.CurrentScreen as ScreenQueue;
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("load screen", () => LoadScreen(new IntroScreen()));
}
[Test]
public void TestBasic()
{
AddUntilStep("wait for queue screen", () => queueScreen?.IsLoaded == true);
AddStep("set users", () =>
{
queueScreen!.Users = Enumerable.Range(0, 10).Select(_ => new APIUser
{
Username = "peppy",
Statistics = new UserStatistics { GlobalRank = 1234 },
Id = RNG.Next(2, 30000000),
}).ToArray();
});
AddStep("change state to idle", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Idle));
AddStep("change state to queueing", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Queueing));
AddStep("change state to found match", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.PendingAccept));
AddStep("change state to waiting for room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom));
AddStep("change state to in room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom));
}
}
}
@@ -0,0 +1,196 @@
// 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.Extensions;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingScreen : MultiplayerTestScene
{
private const int user_count = 8;
private const int beatmap_count = 50;
private MultiplayerRoomUser[] users = null!;
private ScreenMatchmaking screen = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom(MatchType.Matchmaking);
room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
})).ToArray();
JoinRoom(room);
});
WaitForJoined();
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
setupRequestHandler();
AddStep("load match", () =>
{
users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"Player {i}"
}
}).ToArray();
var beatmaps = Enumerable.Range(1, beatmap_count).Select(i => new MultiplayerPlaylistItem
{
BeatmapID = i,
StarRating = i / 10.0
}).ToArray();
LoadScreen(screen = new ScreenMatchmaking(new MultiplayerRoom(0)
{
Users = users,
Playlist = beatmaps
}));
});
AddUntilStep("wait for load", () => screen.IsCurrentScreen());
}
[Test]
public void TestGameplayFlow()
{
for (int round = 1; round <= 3; round++)
{
AddLabel($"Round {round}");
int r = round;
changeStage(MatchmakingStage.RoundWarmupTime, state => state.CurrentRound = r);
changeStage(MatchmakingStage.UserBeatmapSelect);
changeStage(MatchmakingStage.ServerBeatmapFinalised, state =>
{
MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 8).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
state.CandidateItems = beatmaps.Select(b => b.ID).ToArray();
state.CandidateItem = beatmaps[0].ID;
}, waitTime: 35);
changeStage(MatchmakingStage.WaitingForClientsBeatmapDownload);
changeStage(MatchmakingStage.GameplayWarmupTime);
changeStage(MatchmakingStage.Gameplay);
changeStage(MatchmakingStage.ResultsDisplaying);
}
changeStage(MatchmakingStage.Ended, state =>
{
int localUserId = API.LocalUser.Value.OnlineID;
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Rounds[1].Placement = 1;
state.Users[localUserId].Rounds[1].TotalScore = 1;
state.Users[localUserId].Rounds[1].Statistics[HitResult.LargeBonus] = 1;
});
}
private void changeStage(MatchmakingStage stage, Action<MatchmakingRoomState>? prepare = null, int waitTime = 5)
{
AddStep($"stage: {stage}", () => MultiplayerClient.MatchmakingChangeStage(stage, prepare).WaitSafely());
AddWaitStep("wait", waitTime);
}
private void setupRequestHandler()
{
AddStep("setup request handler", () =>
{
Func<APIRequest, bool>? defaultRequestHandler = null;
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapsRequest getBeatmaps:
getBeatmaps.TriggerSuccess(new GetBeatmapsResponse
{
Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap
{
OnlineID = id,
StarRating = id,
DifficultyName = $"Beatmap {id}",
BeatmapSet = new APIBeatmapSet
{
Title = $"Title {id}",
Artist = $"Artist {id}",
AuthorString = $"Author {id}"
}
}).ToList()
});
return true;
case IndexPlaylistScoresRequest index:
var result = new IndexedMultiplayerScores();
for (int i = 0; i < 8; ++i)
{
result.Scores.Add(new MultiplayerScore
{
ID = i,
Accuracy = 1 - (float)i / 16,
Position = i + 1,
EndedAt = DateTimeOffset.Now,
Passed = true,
Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH),
MaxCombo = 1000 - i,
TotalScore = (long)(1_000_000 * (1 - (float)i / 16)),
User = new APIUser { Username = $"user {i}" },
Statistics = new Dictionary<HitResult, int>()
});
}
index.TriggerSuccess(result);
return true;
default:
return defaultRequestHandler?.Invoke(request) ?? false;
}
};
});
}
}
}
@@ -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.
using osu.Framework.Graphics;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePanelRoomAward : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add statistic", () => Child = new PanelRoomAward("Statistic description", 1)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
}
}
@@ -0,0 +1,111 @@
// 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.Screens;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePickScreen : MultiplayerTestScene
{
private readonly IReadOnlyList<APIUser> users = new[]
{
new APIUser
{
Id = 2,
Username = "peppy",
},
new APIUser
{
Id = 1040328,
Username = "smoogipoo",
},
new APIUser
{
Id = 6573093,
Username = "OliBomby",
},
new APIUser
{
Id = 7782553,
Username = "aesth",
},
new APIUser
{
Id = 6411631,
Username = "Maarvin",
}
};
private readonly PlaylistItem[] items = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
})).ToArray();
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom(MatchType.Matchmaking);
room.Playlist = items;
JoinRoom(room);
});
WaitForJoined();
AddStep("add users", () =>
{
foreach (var user in users)
MultiplayerClient.AddUser(user);
});
}
[Test]
public void TestScreen()
{
var selectedItems = new List<long>();
SubScreenBeatmapSelect screen = null!;
AddStep("add screen", () => Child = new ScreenStack(screen = new SubScreenBeatmapSelect()));
AddStep("select maps", () =>
{
selectedItems.Clear();
foreach (var user in users)
{
var item = items[Random.Shared.Next(items.Length)];
selectedItems.Add(item.ID);
Scheduler.AddDelayed(() =>
{
MultiplayerClient.MatchmakingToggleUserSelection(user.Id, item.ID).FireAndForget();
}, RNG.NextDouble(10, 1000));
}
});
AddStep("show final map", () =>
{
long[] candidateItems = selectedItems.ToArray();
long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)];
screen.RollFinalBeatmap(candidateItems, finalItem);
});
}
}
}
@@ -0,0 +1,102 @@
// 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.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePlayerPanel : MultiplayerTestScene
{
private PlayerPanel panel = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add panel", () => Child = panel = new PlayerPanel(new MultiplayerRoomUser(1)
{
User = new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
Statistics = new UserStatistics { GlobalRank = null, CountryRank = null }
}
})
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
[Test]
public void TestIncreasePlacement()
{
int rank = 0;
AddStep("increase placement", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Users =
{
UserDictionary =
{
{
2, new MatchmakingUser
{
UserId = 2,
Placement = ++rank
}
}
}
}
}).WaitSafely());
AddToggleStep("toggle horizontal", h => panel.Horizontal = h);
}
[Test]
public void TestIncreasePoints()
{
int points = 0;
AddStep("increase points", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Users =
{
UserDictionary =
{
{
1, new MatchmakingUser
{
UserId = 1,
Placement = 1,
Points = ++points
}
}
}
}
}).WaitSafely());
}
[Test]
public void TestJump()
{
AddStep("jump", () => MultiplayerClient.SendUserMatchRequest(1, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely());
}
}
}
@@ -0,0 +1,99 @@
// 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.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneResultsScreen : MultiplayerTestScene
{
private const int invalid_user_id = 1;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add results screen", () =>
{
Child = new ScreenStack(new SubScreenResults())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(0.8f)
};
});
AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(invalid_user_id)
{
User = new APIUser
{
Id = invalid_user_id,
Username = "Invalid user"
}
}));
}
[Test]
public void TestResults()
{
AddStep("set results stage", () =>
{
var state = new MatchmakingRoomState
{
CurrentRound = 6,
Stage = MatchmakingStage.Ended
};
int localUserId = API.LocalUser.Value.OnlineID;
// Overall state.
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Points = 8;
state.Users[invalid_user_id].Placement = 2;
state.Users[invalid_user_id].Points = 7;
for (int round = 1; round <= state.CurrentRound; round++)
state.Users[localUserId].Rounds[round].Placement = round;
// Highest score.
state.Users[localUserId].Rounds[1].TotalScore = 1000;
state.Users[invalid_user_id].Rounds[1].TotalScore = 990;
// Highest accuracy.
state.Users[localUserId].Rounds[2].Accuracy = 0.9995;
state.Users[invalid_user_id].Rounds[2].Accuracy = 0.5;
// Highest combo.
state.Users[localUserId].Rounds[3].MaxCombo = 100;
state.Users[invalid_user_id].Rounds[3].MaxCombo = 10;
// Most bonus score.
state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50;
state.Users[invalid_user_id].Rounds[4].Statistics[HitResult.LargeBonus] = 25;
// Smallest score difference.
state.Users[localUserId].Rounds[5].TotalScore = 1000;
state.Users[invalid_user_id].Rounds[5].TotalScore = 999;
// Largest score difference.
state.Users[localUserId].Rounds[6].TotalScore = 1000;
state.Users[invalid_user_id].Rounds[6].TotalScore = 0;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}
@@ -0,0 +1,102 @@
// 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.Graphics;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneRoundResultsScreen : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
setupRequestHandler();
AddStep("load screen", () =>
{
Child = new ScreenStack(new SubScreenRoundResults())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(0.8f)
};
});
}
private void setupRequestHandler()
{
AddStep("setup request handler", () =>
{
Func<APIRequest, bool>? defaultRequestHandler = null;
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapsRequest getBeatmaps:
getBeatmaps.TriggerSuccess(new GetBeatmapsResponse
{
Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap
{
OnlineID = id,
StarRating = id,
DifficultyName = $"Beatmap {id}",
BeatmapSet = new APIBeatmapSet
{
Title = $"Title {id}",
Artist = $"Artist {id}",
AuthorString = $"Author {id}"
}
}).ToList()
});
return true;
case IndexPlaylistScoresRequest index:
var result = new IndexedMultiplayerScores();
for (int i = 0; i < 8; ++i)
{
result.Scores.Add(new MultiplayerScore
{
ID = i,
Accuracy = 1 - (float)i / 16,
Position = i + 1,
EndedAt = DateTimeOffset.Now,
Passed = true,
Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH),
MaxCombo = 1000 - i,
TotalScore = (long)(1_000_000 * (1 - (float)i / 16)),
User = new APIUser { Username = $"user {i}" },
Statistics = new Dictionary<HitResult, int>()
});
}
index.TriggerSuccess(result);
return true;
default:
return defaultRequestHandler?.Invoke(request) ?? false;
}
};
});
}
}
}
@@ -0,0 +1,61 @@
// 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.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageDisplay : MultiplayerTestScene
{
[Cached]
protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum);
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add display", () => Child = new StageDisplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
});
}
[Test]
public void TestChangeStage()
{
addStage(MatchmakingStage.WaitingForClientsJoin);
for (int i = 1; i <= 5; i++)
{
addStage(MatchmakingStage.RoundWarmupTime);
addStage(MatchmakingStage.UserBeatmapSelect);
addStage(MatchmakingStage.ServerBeatmapFinalised);
addStage(MatchmakingStage.WaitingForClientsBeatmapDownload);
addStage(MatchmakingStage.GameplayWarmupTime);
addStage(MatchmakingStage.Gameplay);
addStage(MatchmakingStage.ResultsDisplaying);
}
addStage(MatchmakingStage.Ended);
}
private void addStage(MatchmakingStage stage)
{
AddStep($"{stage}", () => MultiplayerClient.MatchmakingChangeStage(stage).WaitSafely());
AddWaitStep("wait a bit", 10);
}
}
}
@@ -0,0 +1,49 @@
// 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 NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageSegment : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add bubble", () => Child = new StageDisplay.StageSegment(null, MatchmakingStage.RoundWarmupTime, "Next Round")
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
}
[Test]
public void TestStartStopCountdown()
{
MultiplayerCountdown countdown = null!;
AddStep("start countdown", () => MultiplayerClient.StartCountdown(countdown = new MatchmakingStageCountdown
{
Stage = MatchmakingStage.RoundWarmupTime,
TimeRemaining = TimeSpan.FromSeconds(5)
}).WaitSafely());
AddWaitStep("wait a bit", 10);
AddStep("stop countdown", () => MultiplayerClient.StopCountdown(countdown).WaitSafely());
}
}
}
@@ -0,0 +1,41 @@
// 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 NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStatusText : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("create display", () => Child = new StageDisplay.StatusText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
[Test]
public void TestChangeStage()
{
foreach (var stage in Enum.GetValues<MatchmakingStage>())
{
AddStep($"{stage}", () => MultiplayerClient.MatchmakingChangeStage(stage).WaitSafely());
AddWaitStep("wait a bit", 10);
}
}
}
}
@@ -0,0 +1,160 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneUserPanelOverlay : MultiplayerTestScene
{
private PlayerPanelOverlay list = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add list", () => Child = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f),
Child = list = new PlayerPanelOverlay()
});
}
[Test]
public void TestChangeDisplayMode()
{
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split);
AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid);
AddStep("change to hidden mode", () => list.DisplayStyle = PanelDisplayStyle.Hidden);
}
[Test]
public void AddPanelsGrid()
{
AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid);
int userId = 0;
AddRepeatStep("join user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(userId)
{
User = new APIUser
{
Username = $"User {userId}"
}
});
userId++;
}, 8);
}
[Test]
public void AddPanelsSplit()
{
AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split);
int userId = 0;
AddRepeatStep("join user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(userId)
{
User = new APIUser
{
Username = $"User {userId}"
}
});
userId++;
}, 8);
}
[Test]
public void RemovePanels()
{
AddStep("join another user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(1)
{
User = new APIUser
{
Username = "User 1"
}
});
});
AddUntilStep("two panels displayed", () => this.ChildrenOfType<UserPanel>().Count(), () => Is.EqualTo(2));
AddStep("remove a user", () => MultiplayerClient.RemoveUser(new APIUser { Id = 1 }));
AddUntilStep("one panel displayed", () => this.ChildrenOfType<UserPanel>().Count(), () => Is.EqualTo(1));
}
[Test]
public void ChangeRankings()
{
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
AddStep("set random placements", () =>
{
MultiplayerRoom room = MultiplayerClient.ServerRoom!;
int[] placements = Enumerable.Range(1, room.Users.Count).ToArray();
Random.Shared.Shuffle(placements);
MatchmakingRoomState state = new MatchmakingRoomState();
for (int i = 0; i < room.Users.Count; i++)
state.Users[room.Users[i].UserID].Placement = placements[i];
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}
@@ -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;
using System.Linq;
using System.Net;
using NUnit.Framework;
@@ -9,10 +10,12 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Login;
using osu.Game.Overlays.Settings;
@@ -54,7 +57,7 @@ namespace osu.Game.Tests.Visual.Menus
}
[Test]
public void TestLoginSuccess()
public void TestLoginSuccess_EmailVerification()
{
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
@@ -94,6 +97,152 @@ namespace osu.Game.Tests.Visual.Menus
assertDropdownState(UserAction.DoNotDisturb);
}
[Test]
public void TestLoginSuccess_TOTPVerification()
{
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "012345")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "012345");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); });
AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); });
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
assertDropdownState(UserAction.Online);
AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb));
assertDropdownState(UserAction.DoNotDisturb);
}
[Test]
public void TestLoginSuccess_TOTPVerification_FallbackToEmail()
{
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "deadbeef")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
case VerificationMailFallbackRequest verificationMailFallbackRequest:
verificationMailFallbackRequest.TriggerSuccess();
return true;
}
return false;
});
AddStep("request fallback to email", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<OsuSpriteText>().Single(t => t.Text.ToString().Contains("email", StringComparison.InvariantCultureIgnoreCase)));
InputManager.Click(MouseButton.Left);
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "deadbeef");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); });
AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); });
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
assertDropdownState(UserAction.Online);
AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb));
assertDropdownState(UserAction.DoNotDisturb);
}
[Test]
public void TestLoginSuccess_TOTPVerification_TurnedOffMidwayThrough()
{
bool firstAttemptHandled = false;
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
verifySessionRequest.RequiredVerificationMethod = SessionVerificationMethod.EmailMessage;
verifySessionRequest.TriggerFailure(new WebException());
firstAttemptHandled = true;
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "123456");
AddUntilStep("first verification attempt handled", () => firstAttemptHandled);
assertAPIState(APIState.RequiresSecondFactorAuth);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "deadbeef")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "deadbeef");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
}
private void assertDropdownState(UserAction state)
{
AddAssert($"dropdown state is {state}", () => loginOverlay.ChildrenOfType<UserDropdown>().First().Current.Value, () => Is.EqualTo(state));
@@ -278,6 +278,8 @@ namespace osu.Game.Tests.Visual.Navigation
{
advanceToSongSelect();
openSkinEditor();
AddUntilStep("skin editor visible", () => skinEditor.State.Value == Visibility.Visible);
AddStep("select autoplay", () => Game.SelectedMods.Value = new Mod[] { new OsuModAutoplay() });
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
@@ -290,8 +292,9 @@ namespace osu.Game.Tests.Visual.Navigation
AddAssert("settings not visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.EqualTo(0));
toggleSkinEditor();
AddUntilStep("skin editor hidden", () => skinEditor.State.Value == Visibility.Hidden);
AddStep("move cursor slightly", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(1)));
AddStep("move cursor slightly", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(2)));
AddUntilStep("settings visible", () => getPlayerSettingsOverlay().DrawWidth, () => Is.GreaterThan(0));
AddStep("move cursor to right of screen too far", () => InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.TopRight + new Vector2(10240, 0)));
@@ -9,6 +9,7 @@ using osu.Framework.Extensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
@@ -217,6 +218,26 @@ namespace osu.Game.Tests.Visual.Navigation
AddAssert("leaderboard matches gameplay beatmap", () => Game.ChildrenOfType<LeaderboardManager>().Single().CurrentCriteria?.Beatmap, () => Is.EqualTo(beatmap().BeatmapInfo));
}
[Test]
public void TestEnterKeyProgressesToGameplayEvenIfCarouselFilteredOut()
{
PushAndConfirm(() => new SoloSongSelect());
AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("filter out active beatmap", () => this.ChildrenOfType<SearchTextBox>().First().Text = "abacadabadaeba");
AddUntilStep("wait for filter", () => this.ChildrenOfType<BeatmapCarousel>().Single().IsFiltering, () => Is.False);
AddStep("press enter", () => InputManager.Key(Key.Enter));
AddUntilStep("player entered", () =>
{
DismissAnyNotifications();
return Game.ScreenStack.CurrentScreen is Player;
});
}
private Func<Player> playToResults()
{
var player = playToCompletion();
@@ -86,11 +86,11 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3"))));
AddAssert("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddUntilStep("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll<BeatmapCollection>()));
AddAssert("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
AddUntilStep("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
}
[Test]
@@ -185,11 +185,11 @@ namespace osu.Game.Tests.Visual.SongSelect
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddUntilStep("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddUntilStep("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
@@ -390,7 +390,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
var groupModel = (GroupDefinition)groupItem.Model;
Assert.That(groupModel.Title, Is.EqualTo(expectedTitle));
Assert.That(groupModel.Title.ToString(), Is.EqualTo(expectedTitle));
Assert.That(itemsInGroup.Select(i => i.Model).OfType<GroupedBeatmap>().Select(gb => gb.Beatmap), Is.EquivalentTo(expectedBeatmaps));
totalItems += itemsInGroup.Count() + 1;
@@ -249,5 +249,39 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckDisplayedBeatmapSetsCount(10);
CheckDisplayedBeatmapsCount(30);
}
[Test]
public void TestGroupDoesNotExpandAgainOnRefilterIfManuallyCollapsed()
{
ApplyToFilterAndWaitForFilter("filter", c => c.SearchText = BeatmapSets[2].Metadata.Title);
CheckDisplayedGroupsCount(1);
CheckDisplayedBeatmapSetsCount(1);
CheckDisplayedBeatmapsCount(3);
CheckHasSelection();
ApplyToFilterAndWaitForFilter("remove filter", c => c.SearchText = string.Empty);
CheckDisplayedGroupsCount(5);
CheckDisplayedBeatmapSetsCount(10);
CheckDisplayedBeatmapsCount(30);
ToggleGroupCollapse();
ApplyToFilterAndWaitForFilter("apply no-op filter", c => c.AllowConvertedBeatmaps = !c.AllowConvertedBeatmaps);
AddAssert("group didn't re-expand", () => Carousel.ExpandedGroup, () => Is.Null);
ToggleGroupCollapse();
AddAssert("beatmap set re-expanded correctly", () => Carousel.ExpandedBeatmapSet?.BeatmapSet, () => Is.EqualTo(BeatmapSets[2]));
ApplyToFilterAndWaitForFilter("filter", c => c.SearchText = BeatmapSets[1].Metadata.Title);
CheckDisplayedGroupsCount(1);
CheckDisplayedBeatmapSetsCount(1);
CheckDisplayedBeatmapsCount(3);
CheckHasSelection();
}
}
}
@@ -337,5 +337,31 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddAssert("expanded group is still first", () => (Carousel.ExpandedGroup as StarDifficultyGroupDefinition)?.Difficulty.Stars, () => Is.EqualTo(0));
}
[Test]
public void TestExpandedGroupDoesNotExpandAgainOnRefilterIfManuallyCollapsed()
{
SelectPrevSet();
WaitForBeatmapSelection(2, 9);
AddAssert("expanded group is last", () => (Carousel.ExpandedGroup as StarDifficultyGroupDefinition)?.Difficulty.Stars, () => Is.EqualTo(6));
SelectNextPanel();
Select();
WaitForBeatmapSelection(2, 9);
AddAssert("expanded group is first", () => (Carousel.ExpandedGroup as StarDifficultyGroupDefinition)?.Difficulty.Stars, () => Is.EqualTo(0));
ToggleGroupCollapse();
// doesn't actually filter anything away, but triggers a filter.
ApplyToFilterAndWaitForFilter("filter", c => c.SearchText = "Some");
AddAssert("group didn't re-expand", () => (Carousel.ExpandedGroup as StarDifficultyGroupDefinition)?.Difficulty.Stars, () => Is.Null);
ToggleGroupCollapse();
ApplyToFilterAndWaitForFilter("filter", c => c.SearchText = "Som");
AddAssert("expanded group is first", () => (Carousel.ExpandedGroup as StarDifficultyGroupDefinition)?.Difficulty.Stars, () => Is.EqualTo(0));
}
}
}
@@ -87,11 +87,11 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3"))));
AddAssert("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddUntilStep("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll<BeatmapCollection>()));
AddAssert("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
AddUntilStep("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
}
[Test]
@@ -186,11 +186,11 @@ namespace osu.Game.Tests.Visual.SongSelectV2
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddUntilStep("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddUntilStep("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
@@ -9,6 +9,7 @@ using osu.Game.Beatmaps;
using osu.Game.Overlays;
using osu.Game.Graphics.Carousel;
using osu.Game.Graphics.Cursor;
using osu.Game.Scoring;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Visual.UserInterface;
using osuTK;
@@ -86,6 +87,64 @@ namespace osu.Game.Tests.Visual.SongSelectV2
}
}
[Test]
public void TestRanks()
{
for (int i = -1; i <= 7; i++)
{
ScoreRank rank = (ScoreRank)i;
AddStep($"display rank {rank}", () =>
{
ContentContainer.Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = new (Type, object)[]
{
(typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
},
Child = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 0.5f,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, 5f),
Children = new[]
{
new PanelGroupRankDisplay
{
Item = new CarouselItem(new RankDisplayGroupDefinition(rank))
},
new PanelGroupRankDisplay
{
Item = new CarouselItem(new RankDisplayGroupDefinition(rank)),
KeyboardSelected = { Value = true },
},
new PanelGroupRankDisplay
{
Item = new CarouselItem(new RankDisplayGroupDefinition(rank)),
Expanded = { Value = true },
},
new PanelGroupRankDisplay
{
Item = new CarouselItem(new RankDisplayGroupDefinition(rank)),
Expanded = { Value = true },
KeyboardSelected = { Value = true },
},
},
}
}
};
});
}
}
protected override Drawable CreateContent()
{
return new OsuContextMenuContainer
@@ -55,22 +55,26 @@ namespace osu.Game.Tests.Visual.SongSelectV2
waitForFiltering(6);
BeatmapInfo? initiallySelected = null;
AddAssert("selected is taiko", () => (initiallySelected = selectedBeatmap)?.Ruleset.OnlineID, () => Is.EqualTo(1));
AddAssert("carousel beatmap is taiko", () => (initiallySelected = selectedBeatmap)?.Ruleset.OnlineID, () => Is.EqualTo(1));
AddUntilStep("global beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));
ChangeRuleset(0);
waitForFiltering(7);
AddAssert("selected is osu", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(0));
AddAssert("selected is same set as original", () => selectedBeatmap?.BeatmapSet, () => Is.EqualTo(initiallySelected!.BeatmapSet));
AddAssert("carousel beatmap is osu", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(0));
AddUntilStep("global beatmap is osu", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(0));
AddAssert("carousel beatmap is same set as original", () => selectedBeatmap?.BeatmapSet, () => Is.EqualTo(initiallySelected!.BeatmapSet));
ChangeRuleset(1);
waitForFiltering(8);
AddAssert("selected is taiko", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(1));
AddAssert("selected is same set as original", () => selectedBeatmap?.BeatmapSet, () => Is.EqualTo(initiallySelected!.BeatmapSet));
AddAssert("carousel beatmap is taiko", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(1));
AddUntilStep("global beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));
AddAssert("carousel beatmap is same set as original", () => selectedBeatmap?.BeatmapSet, () => Is.EqualTo(initiallySelected!.BeatmapSet));
ChangeRuleset(2);
waitForFiltering(9);
AddAssert("selected is catch", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(2));
AddAssert("selected is different set", () => selectedBeatmap?.BeatmapSet, () => Is.Not.EqualTo(initiallySelected!.BeatmapSet));
AddAssert("carousel beatmap is catch", () => selectedBeatmap?.Ruleset.OnlineID, () => Is.EqualTo(2));
AddUntilStep("global beatmap is catch", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(2));
AddAssert("carousel beatmap is different set", () => selectedBeatmap?.BeatmapSet, () => Is.Not.EqualTo(initiallySelected!.BeatmapSet));
}
/// <summary>
@@ -245,7 +245,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
GroupBy(GroupMode.RankAchieved);
WaitForFiltering();
assertGroupPresent("S+", () => new[] { beatmapSets[0] });
assertGroupPresent("Silver S", () => new[] { beatmapSets[0] });
assertGroupPresent("A", () => new[] { beatmapSets[1] });
assertGroupPresent("C", () => new[] { beatmapSets[2] });
assertGroupPresent("Unplayed", () => new[] { beatmapSets[3], beatmapSets[4] });
@@ -316,7 +316,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
AddAssert($"\"{name}\" present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == name);
var group = grouping.GroupItems.Single(g => g.Key.Title.ToString() == name);
var actualBeatmaps = group.Value.Select(i => i.Model).OfType<GroupedBeatmap>().Select(gb => gb.Beatmap).OrderBy(b => b.ID);
var expectedBeatmaps = getBeatmaps().SelectMany(s => s.Beatmaps).OrderBy(b => b.ID);
return actualBeatmaps.SequenceEqual(expectedBeatmaps);
@@ -68,14 +68,15 @@ namespace osu.Game.Tests.Visual.UserInterface
}
[TestCase(Key.P, Key.P)]
[TestCase(Key.M, Key.P)]
[TestCase(Key.L, Key.P)]
[TestCase(Key.B, Key.E)]
[TestCase(Key.S, Key.E)]
[TestCase(Key.D, null)]
[TestCase(Key.Q, null)]
[TestCase(Key.O, null)]
public void TestShortcutKeys(Key key, Key? subMenuEnterKey)
[TestCase(Key.M, Key.M, Key.L)]
[TestCase(Key.M, Key.M, Key.M)]
[TestCase(Key.L, Key.L)]
[TestCase(Key.B, Key.E, Key.B)]
[TestCase(Key.S, Key.E, Key.S)]
[TestCase(Key.D)]
[TestCase(Key.Q)]
[TestCase(Key.O)]
public void TestShortcutKeys(params Key[] keys)
{
int activationCount = -1;
AddStep("set up action", () =>
@@ -83,7 +84,7 @@ namespace osu.Game.Tests.Visual.UserInterface
activationCount = 0;
void action() => activationCount++;
switch (key)
switch (keys.First())
{
case Key.P:
buttons.OnSolo = action;
@@ -119,16 +120,19 @@ namespace osu.Game.Tests.Visual.UserInterface
}
});
AddStep($"press {key}", () => InputManager.Key(key));
// trigger out of idle state
AddStep($"press {keys.First()}", () => InputManager.Key(keys.First()));
AddAssert("state is top level", () => buttons.State == ButtonSystemState.TopLevel);
if (subMenuEnterKey != null)
for (int i = 0; i < keys.Length; i++)
{
AddStep($"press {subMenuEnterKey}", () => InputManager.Key(subMenuEnterKey.Value));
AddAssert("state is not top menu", () => buttons.State != ButtonSystemState.TopLevel);
var key = keys[i];
AddStep($"press {key}", () => InputManager.Key(key));
if (i > 0)
AddAssert("state is not top menu", () => buttons.State != ButtonSystemState.TopLevel);
}
AddStep($"press {key}", () => InputManager.Key(key));
AddAssert("action triggered", () => activationCount == 1);
}
@@ -13,8 +13,8 @@ using osu.Game.Online.Metadata;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.Menu;
using osuTK.Graphics;
using osuTK.Input;
using Color4 = osuTK.Graphics.Color4;
namespace osu.Game.Tests.Visual.UserInterface
{
@@ -24,7 +24,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
public const float TRANSITION_DURATION = 340;
public const float CORNER_RADIUS = 8;
protected const float WIDTH = 345;
public const float WIDTH = 345;
public IBindable<bool> Expanded { get; }
@@ -77,25 +77,27 @@ namespace osu.Game.Beatmaps.Drawables.Cards
containingInputManager = GetContainingInputManager();
Action = () =>
{
if (containingInputManager?.CurrentState.Keyboard.ShiftPressed == true)
{
switch (DownloadTracker.State.Value)
{
case DownloadState.NotDownloaded:
if (!BeatmapSet.Availability.DownloadDisabled)
beatmaps?.Download(BeatmapSet, preferNoVideo.Value);
break;
if (Action == null)
throw new InvalidOperationException($"An action should be assigned to this {nameof(BeatmapCard)}. To use the default, assign {nameof(DefaultAction)}.");
}
case DownloadState.LocallyAvailable:
game?.PresentBeatmap(BeatmapSet);
break;
}
protected void DefaultAction()
{
if (containingInputManager?.CurrentState.Keyboard.ShiftPressed == true)
{
switch (DownloadTracker.State.Value)
{
case DownloadState.NotDownloaded:
if (!BeatmapSet.Availability.DownloadDisabled) beatmaps?.Download(BeatmapSet, preferNoVideo.Value);
break;
case DownloadState.LocallyAvailable:
game?.PresentBeatmap(BeatmapSet);
break;
}
else
beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID);
};
}
else
beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID);
}
protected override bool OnHover(HoverEvent e)
@@ -21,7 +21,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public BeatmapCardContentBackground(IBeatmapSetOnlineInfo onlineInfo)
public BeatmapCardContentBackground(IBeatmapSetOnlineInfo onlineInfo, bool keepLoaded = false)
{
InternalChildren = new Drawable[]
{
@@ -29,7 +29,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
RelativeSizeAxes = Axes.Both,
},
cover = new DelayedLoadUnloadWrapper(() => createCover(onlineInfo), 500, 500)
cover = new DelayedLoadUnloadWrapper(() => createCover(onlineInfo), keepLoaded ? 0 : 500, keepLoaded ? double.MaxValue : 1000)
{
RelativeSizeAxes = Axes.Both,
Colour = Colour4.Transparent
@@ -46,6 +46,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
: base(beatmapSet, allowExpansion)
{
content = new BeatmapCardContent(height);
Action = DefaultAction;
}
[BackgroundDependencyLoader(true)]
@@ -54,6 +54,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
: base(beatmapSet, false)
{
content = new BeatmapCardContent(height);
Action = DefaultAction;
}
[BackgroundDependencyLoader]
@@ -47,6 +47,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
: base(beatmapSet, allowExpansion)
{
content = new BeatmapCardContent(HEIGHT);
Action = DefaultAction;
}
[BackgroundDependencyLoader]
@@ -35,11 +35,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public BeatmapCardThumbnail(IBeatmapSetInfo beatmapSetInfo, IBeatmapSetOnlineInfo onlineInfo)
public BeatmapCardThumbnail(IBeatmapSetInfo beatmapSetInfo, IBeatmapSetOnlineInfo onlineInfo, bool keepLoaded = false)
{
InternalChildren = new Drawable[]
{
new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List)
new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List, keepLoaded ? 0 : 500, keepLoaded ? double.MaxValue : 1000)
{
RelativeSizeAxes = Axes.Both,
OnlineInfo = onlineInfo
@@ -16,10 +16,12 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons
private readonly Bindable<DownloadState> state = new Bindable<DownloadState>();
private readonly APIBeatmapSet beatmapSet;
private readonly bool allowNavigationToBeatmap;
public GoToBeatmapButton(APIBeatmapSet beatmapSet)
public GoToBeatmapButton(APIBeatmapSet beatmapSet, bool allowNavigationToBeatmap)
{
this.beatmapSet = beatmapSet;
this.allowNavigationToBeatmap = allowNavigationToBeatmap;
}
[BackgroundDependencyLoader(true)]
@@ -27,7 +29,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons
{
Action = () => game?.PresentBeatmap(beatmapSet);
Icon.Icon = FontAwesome.Solid.AngleDoubleRight;
TooltipText = "Go to beatmap";
}
protected override void LoadComplete()
@@ -40,8 +41,31 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons
private void updateState()
{
Enabled.Value = state.Value == DownloadState.LocallyAvailable;
this.FadeTo(Enabled.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
bool available = state.Value == DownloadState.LocallyAvailable;
Enabled.Value = allowNavigationToBeatmap && available;
float alpha;
if (available && allowNavigationToBeatmap)
{
TooltipText = "Go to beatmap";
Enabled.Value = true;
alpha = 1f;
}
else if (available)
{
TooltipText = string.Empty;
Enabled.Value = false;
alpha = 0.3f;
}
else
{
TooltipText = string.Empty;
Enabled.Value = false;
alpha = 0;
}
this.FadeTo(alpha, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
}
}
}
@@ -30,7 +30,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards
set
{
buttonsExpandedWidth = value;
buttonArea.Width = value;
if (IsLoaded)
updateState();
}
@@ -67,7 +66,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public CollapsibleButtonContainer(APIBeatmapSet beatmapSet)
public CollapsibleButtonContainer(APIBeatmapSet beatmapSet, bool allowNavigationToBeatmap = true, bool keepBackgroundLoaded = false)
{
downloadTracker = new BeatmapDownloadTracker(beatmapSet);
@@ -116,14 +115,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
},
new GoToBeatmapButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State },
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
}
}
}
},
@@ -135,7 +126,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
Masking = true,
Children = new Drawable[]
{
new BeatmapCardContentBackground(beatmapSet)
new BeatmapCardContentBackground(beatmapSet, keepBackgroundLoaded)
{
RelativeSizeAxes = Axes.Both,
Dimmed = { BindTarget = ShowDetails }
@@ -152,6 +143,15 @@ namespace osu.Game.Beatmaps.Drawables.Cards
}
}
};
buttons.Add(new GoToBeatmapButton(beatmapSet, allowNavigationToBeatmap)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State },
RelativeSizeAxes = Axes.Both,
Height = 0.5f,
});
}
protected override void LoadComplete()
@@ -165,6 +165,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards
private void updateState()
{
buttonArea.Width = buttonsExpandedWidth;
float buttonAreaWidth = ShowDetails.Value ? ButtonsExpandedWidth : ButtonsCollapsedWidth;
float mainAreaWidth = Width - buttonAreaWidth;
@@ -36,7 +36,7 @@ namespace osu.Game.Extensions
string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty;
return FormattableString.Invariant($"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}")}");
return $"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}", CultureInfo.InvariantCulture)}";
}
/// <summary>
+10 -7
View File
@@ -453,8 +453,7 @@ namespace osu.Game.Graphics.Carousel
// matching with exact modifier consideration (so Ctrl+Enter would be ignored).
case Key.Enter:
case Key.KeypadEnter:
activateSelection();
return true;
return activateSelection();
}
return base.OnKeyDown(e);
@@ -465,8 +464,7 @@ namespace osu.Game.Graphics.Carousel
switch (e.Action)
{
case GlobalAction.Select:
activateSelection();
return true;
return activateSelection();
// the selection traversal handlers below are scheduled to avoid an issue
// wherein if the update frame rate is low, keeping one of the actions below pressed leads to selection moving back to the start / end.
@@ -560,10 +558,15 @@ namespace osu.Game.Graphics.Carousel
{
}
private void activateSelection()
private bool activateSelection()
{
if (currentKeyboardSelection.CarouselItem != null)
{
Activate(currentKeyboardSelection.CarouselItem);
return true;
}
return false;
}
private void traverseKeyboardSelection(int direction)
@@ -761,10 +764,10 @@ namespace osu.Game.Graphics.Carousel
updateItemYPosition(item, ref lastVisible, ref yPos);
if (CheckModelEquality(item.Model, currentKeyboardSelection.Model!))
currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, item.CarouselYPosition, i);
currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, item.CarouselYPosition + item.DrawHeight / 2, i);
if (CheckModelEquality(item.Model, currentSelection.Model!))
currentSelection = new Selection(currentSelection.Model, item, item.CarouselYPosition, i);
currentSelection = new Selection(currentSelection.Model, item, item.CarouselYPosition + item.DrawHeight / 2, i);
}
// Update the total height of all items (to make the scroll container scrollable through the full height even though
@@ -18,6 +18,8 @@ namespace osu.Game.Graphics.Containers
private readonly Container content = new Container { RelativeSizeAxes = Axes.Both };
private HoverSounds samples = null!;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
// base call is checked for cases when `OsuClickableContainer` has masking applied to it directly (ie. externally in object initialisation).
base.ReceivePositionalInputAt(screenSpacePos)
@@ -33,6 +35,14 @@ namespace osu.Game.Graphics.Containers
this.sampleSet = sampleSet;
}
public void TriggerClickWithSound()
{
TriggerClick();
// TriggerClick doesn't recursively fire the event so we need to manually do this.
(samples as HoverClickSounds)?.PlayClickSample();
}
public virtual LocalisableString TooltipText { get; set; }
[BackgroundDependencyLoader]
@@ -46,7 +56,7 @@ namespace osu.Game.Graphics.Containers
AddRangeInternal(new Drawable[]
{
CreateHoverSounds(sampleSet),
samples = CreateHoverSounds(sampleSet),
content,
});
}
+35 -71
View File
@@ -16,77 +16,19 @@ namespace osu.Game.Graphics
{
public static class OsuIcon
{
#region Legacy spritesheet-based icons
private static IconUsage get(int icon) => new IconUsage((char)icon, @"osuFont");
// ruleset icons in circles
public static IconUsage RulesetOsu => get(0xe000);
public static IconUsage RulesetMania => get(0xe001);
public static IconUsage RulesetCatch => get(0xe002);
public static IconUsage RulesetTaiko => get(0xe003);
// ruleset icons without circles
public static IconUsage FilledCircle => get(0xe004);
public static IconUsage Logo => get(0xe006);
public static IconUsage ChevronDownCircle => get(0xe007);
public static IconUsage EditCircle => get(0xe033);
public static IconUsage LeftCircle => get(0xe034);
public static IconUsage RightCircle => get(0xe035);
public static IconUsage Charts => get(0xe036);
public static IconUsage Solo => get(0xe037);
public static IconUsage Multi => get(0xe038);
public static IconUsage Gear => get(0xe039);
// misc icons
public static IconUsage Bat => get(0xe008);
public static IconUsage Bubble => get(0xe009);
public static IconUsage BubblePop => get(0xe02e);
public static IconUsage Dice => get(0xe011);
public static IconUsage HeartBreak => get(0xe030);
public static IconUsage Hot => get(0xe031);
public static IconUsage ListSearch => get(0xe032);
//osu! playstyles
public static IconUsage PlayStyleTablet => get(0xe02a);
public static IconUsage PlayStyleMouse => get(0xe029);
public static IconUsage PlayStyleKeyboard => get(0xe02b);
public static IconUsage PlayStyleTouch => get(0xe02c);
// osu! difficulties
public static IconUsage EasyOsu => get(0xe015);
public static IconUsage NormalOsu => get(0xe016);
public static IconUsage HardOsu => get(0xe017);
public static IconUsage InsaneOsu => get(0xe018);
public static IconUsage ExpertOsu => get(0xe019);
// taiko difficulties
public static IconUsage EasyTaiko => get(0xe01a);
public static IconUsage NormalTaiko => get(0xe01b);
public static IconUsage HardTaiko => get(0xe01c);
public static IconUsage InsaneTaiko => get(0xe01d);
public static IconUsage ExpertTaiko => get(0xe01e);
// fruits difficulties
public static IconUsage EasyFruits => get(0xe01f);
public static IconUsage NormalFruits => get(0xe020);
public static IconUsage HardFruits => get(0xe021);
public static IconUsage InsaneFruits => get(0xe022);
public static IconUsage ExpertFruits => get(0xe023);
// mania difficulties
public static IconUsage EasyMania => get(0xe024);
public static IconUsage NormalMania => get(0xe025);
public static IconUsage HardMania => get(0xe026);
public static IconUsage InsaneMania => get(0xe027);
public static IconUsage ExpertMania => get(0xe028);
#endregion
#region New single-file-based icons
public const string FONT_NAME = @"Icons";
// ruleset icons
public static IconUsage RulesetOsu => get(OsuIconMapping.RulesetOsu);
public static IconUsage RulesetMania => get(OsuIconMapping.RulesetMania);
public static IconUsage RulesetCatch => get(OsuIconMapping.RulesetCatch);
public static IconUsage RulesetTaiko => get(OsuIconMapping.RulesetTaiko);
public static IconUsage Logo => get(OsuIconMapping.Logo);
public static IconUsage EditCircle => get(OsuIconMapping.EditCircle);
public static IconUsage LeftCircle => get(OsuIconMapping.LeftCircle);
public static IconUsage RightCircle => get(OsuIconMapping.RightCircle);
public static IconUsage Audio => get(OsuIconMapping.Audio);
public static IconUsage Beatmap => get(OsuIconMapping.Beatmap);
public static IconUsage Calendar => get(OsuIconMapping.Calendar);
@@ -246,6 +188,30 @@ namespace osu.Game.Graphics
private enum OsuIconMapping
{
[Description(@"Logo")]
Logo,
[Description(@"RulesetOsu")]
RulesetOsu,
[Description(@"RulesetMania")]
RulesetMania,
[Description(@"RulesetCatch")]
RulesetCatch,
[Description(@"RulesetTaiko")]
RulesetTaiko,
[Description(@"EditCircle")]
EditCircle,
[Description(@"LeftCircle")]
LeftCircle,
[Description(@"RightCircle")]
RightCircle,
[Description(@"audio")]
Audio,
@@ -737,7 +703,5 @@ namespace osu.Game.Graphics
textures.Dispose();
}
}
#endregion
}
}
@@ -42,18 +42,20 @@ namespace osu.Game.Graphics.UserInterface
this.buttons = buttons ?? new[] { MouseButton.Left };
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
sampleClick = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select")
?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select");
sampleClickDisabled = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select-disabled")
?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select-disabled");
}
protected override bool OnClick(ClickEvent e)
{
if (buttons.Contains(e.Button))
{
var channel = Enabled.Value ? sampleClick?.GetChannel() : sampleClickDisabled?.GetChannel();
if (channel != null)
{
channel.Frequency.Value = 0.99 + RNG.NextDouble(0.02);
channel.Play();
}
}
PlayClickSample();
return base.OnClick(e);
}
@@ -66,14 +68,15 @@ namespace osu.Game.Graphics.UserInterface
base.PlayHoverSample();
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
public void PlayClickSample()
{
sampleClick = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select")
?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select");
var channel = Enabled.Value ? sampleClick?.GetChannel() : sampleClickDisabled?.GetChannel();
sampleClickDisabled = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select-disabled")
?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select-disabled");
if (channel != null)
{
channel.Frequency.Value = 0.99 + RNG.NextDouble(0.02);
channel.Play();
}
}
}
}
+16 -2
View File
@@ -15,6 +15,7 @@ using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ExceptionExtensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
@@ -52,6 +53,8 @@ namespace osu.Game.Online.API
public string ProvidedUsername { get; private set; }
public SessionVerificationMethod? SessionVerificationMethod { get; set; }
public string SecondFactorCode { get; private set; }
private string password;
@@ -292,7 +295,17 @@ namespace osu.Game.Online.API
verificationRequest.Failure += ex =>
{
state.Value = APIState.RequiresSecondFactorAuth;
LastLoginError = ex;
if (verificationRequest.RequiredVerificationMethod != null)
{
SessionVerificationMethod = verificationRequest.RequiredVerificationMethod;
LastLoginError = new APIException($"Must use {SessionVerificationMethod.GetDescription().ToLowerInvariant()} to complete verification.", ex);
}
else
{
LastLoginError = ex;
}
SecondFactorCode = null;
};
@@ -337,7 +350,8 @@ namespace osu.Game.Online.API
localUser.Value = me;
configSupporter.Value = me.IsSupporter;
state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth;
SessionVerificationMethod = me.SessionVerificationMethod;
state.Value = SessionVerificationMethod == null ? APIState.Online : APIState.RequiresSecondFactorAuth;
failureCount = 0;
};
+16 -5
View File
@@ -5,6 +5,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
@@ -62,7 +63,8 @@ namespace osu.Game.Online.API
private bool shouldFailNextLogin;
private bool stayConnectingNextLogin;
private bool requiredSecondFactorAuth = true;
public SessionVerificationMethod? SessionVerificationMethod { get; set; } = Requests.Responses.SessionVerificationMethod.EmailMessage;
/// <summary>
/// The current connectivity state of the API.
@@ -130,14 +132,14 @@ namespace osu.Game.Online.API
Id = DUMMY_USER_ID,
};
if (requiredSecondFactorAuth)
if (SessionVerificationMethod != null)
{
state.Value = APIState.RequiresSecondFactorAuth;
}
else
{
onSuccessfulLogin();
requiredSecondFactorAuth = true;
SessionVerificationMethod = null;
}
}
@@ -147,7 +149,16 @@ namespace osu.Game.Online.API
request.Failure += e =>
{
state.Value = APIState.RequiresSecondFactorAuth;
LastLoginError = e;
if (request.RequiredVerificationMethod != null)
{
SessionVerificationMethod = request.RequiredVerificationMethod;
LastLoginError = new APIException($"Must use {SessionVerificationMethod.GetDescription().ToLowerInvariant()} to complete verification.", e);
}
else
{
LastLoginError = e;
}
};
state.Value = APIState.Connecting;
@@ -204,7 +215,7 @@ namespace osu.Game.Online.API
/// <summary>
/// Skip 2FA requirement for next login.
/// </summary>
public void SkipSecondFactor() => requiredSecondFactorAuth = false;
public void SkipSecondFactor() => SessionVerificationMethod = null;
/// <summary>
/// During the next simulated login, the process will fail immediately.
+6 -1
View File
@@ -107,10 +107,15 @@ namespace osu.Game.Online.API
/// <param name="password">The user's password.</param>
void Login(string username, string password);
/// <summary>
/// The <see cref="SessionVerificationMethod"/> requested by the server to complete verification.
/// </summary>
SessionVerificationMethod? SessionVerificationMethod { get; }
/// <summary>
/// Provide a second-factor authentication code for authentication.
/// </summary>
/// <param name="code">The 2FA code.</param>
/// <paramref name="code">The 2FA code.</paramref>
void AuthenticateSecondFactor(string code);
/// <summary>
@@ -1,13 +1,26 @@
// 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.ComponentModel;
using System.Runtime.Serialization;
using Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class APIMe : APIUser
{
[JsonProperty("session_verified")]
public bool SessionVerified { get; set; }
[JsonProperty("session_verification_method")]
public SessionVerificationMethod? SessionVerificationMethod { get; set; }
}
public enum SessionVerificationMethod
{
[Description("Timed one-time password")]
[EnumMember(Value = "totp")]
TimedOneTimePassword,
[Description("E-mail")]
[EnumMember(Value = "mail")]
EmailMessage,
}
}
@@ -0,0 +1,20 @@
// 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.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class VerificationMailFallbackRequest : APIRequest
{
protected override string Target => @"session/verify/mail-fallback";
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
return req;
}
}
}
@@ -2,7 +2,9 @@
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using Newtonsoft.Json;
using osu.Framework.IO.Network;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
@@ -13,6 +15,16 @@ namespace osu.Game.Online.API.Requests
public VerifySessionRequest(string verificationKey)
{
VerificationKey = verificationKey;
Failure += _ =>
{
string? response = WebRequest?.GetResponseString();
if (string.IsNullOrEmpty(response))
return;
var responseObject = JsonConvert.DeserializeObject<VerificationFailureResponse>(response);
RequiredVerificationMethod = responseObject?.RequiredSessionVerificationMethod;
};
}
protected override WebRequest CreateWebRequest()
@@ -26,5 +38,13 @@ namespace osu.Game.Online.API.Requests
}
protected override string Target => @"session/verify";
public SessionVerificationMethod? RequiredVerificationMethod { get; internal set; }
private class VerificationFailureResponse
{
[JsonProperty("method")]
public SessionVerificationMethod RequiredSessionVerificationMethod { get; set; }
}
}
}
+21 -5
View File
@@ -1,7 +1,6 @@
// 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.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
@@ -53,9 +52,9 @@ namespace osu.Game.Online.Leaderboards
Origin = Anchor.Centre,
Spacing = new Vector2(-3, 0),
Padding = new MarginPadding { Top = 5 },
Colour = GetRankNameColour(rank),
Colour = GetRankLetterColour(rank),
Font = OsuFont.Numeric.With(size: 25),
Text = GetRankName(rank),
Text = GetRankLetter(rank),
ShadowColour = Color4.Black.Opacity(0.3f),
ShadowOffset = new Vector2(0, 0.08f),
Shadow = true,
@@ -65,12 +64,29 @@ namespace osu.Game.Online.Leaderboards
};
}
public static string GetRankName(ScoreRank rank) => rank.GetDescription().TrimEnd('+');
/// <summary>
/// Returns letters to be shown in places where ranks are shown on a badge or similar to the user.
/// </summary>
public static string GetRankLetter(ScoreRank rank)
{
switch (rank)
{
case ScoreRank.SH:
return @"S";
case ScoreRank.X:
case ScoreRank.XH:
return @"SS";
default:
return rank.ToString();
}
}
/// <summary>
/// Retrieves the grade text colour.
/// </summary>
public static ColourInfo GetRankNameColour(ScoreRank rank)
public static ColourInfo GetRankLetterColour(ScoreRank rank)
{
switch (rank)
{
@@ -0,0 +1,13 @@
// 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.Matchmaking.Events
{
/// <summary>
/// An action performed on a user's avatar in a matchmaking room.
/// </summary>
public enum MatchmakingAvatarAction
{
Jump
}
}
@@ -0,0 +1,29 @@
// 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 MessagePack;
using osu.Game.Online.Multiplayer;
namespace osu.Game.Online.Matchmaking.Events
{
/// <summary>
/// An action performed by a user in a matchmaking room.
/// </summary>
[Serializable]
[MessagePackObject]
public class MatchmakingAvatarActionEvent : MatchServerEvent
{
/// <summary>
/// The user performing the action.
/// </summary>
[Key(0)]
public int UserId { get; set; }
/// <summary>
/// The action.
/// </summary>
[Key(1)]
public MatchmakingAvatarAction Action { get; set; }
}
}
@@ -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.
using System;
using MessagePack;
using osu.Game.Online.Multiplayer;
namespace osu.Game.Online.Matchmaking.Events
{
/// <summary>
/// Requests to perform an action on a user's avatar in a matchmaking room.
/// </summary>
[Serializable]
[MessagePackObject]
public class MatchmakingAvatarActionRequest : MatchUserRequest
{
/// <summary>
/// The action.
/// </summary>
[Key(0)]
public MatchmakingAvatarAction Action { get; set; }
}
}
@@ -3,6 +3,7 @@
using System;
using MessagePack;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer.Countdown;
namespace osu.Game.Online.Multiplayer
@@ -15,6 +16,7 @@ namespace osu.Game.Online.Multiplayer
// IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types.
[Union(0, typeof(CountdownStartedEvent))]
[Union(1, typeof(CountdownStoppedEvent))]
[Union(2, typeof(MatchmakingAvatarActionEvent))]
public abstract class MatchServerEvent
{
}
@@ -3,6 +3,7 @@
using System;
using MessagePack;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
@@ -17,6 +18,7 @@ namespace osu.Game.Online.Multiplayer
[Union(0, typeof(ChangeTeamRequest))]
[Union(1, typeof(StartMatchCountdownRequest))]
[Union(2, typeof(StopCountdownRequest))]
[Union(3, typeof(MatchmakingAvatarActionRequest))]
public abstract class MatchUserRequest
{
}
@@ -17,6 +17,7 @@ using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
@@ -26,7 +27,7 @@ using osu.Game.Utils;
namespace osu.Game.Online.Multiplayer
{
public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer, IMatchmakingServer, IMatchmakingClient
{
public Action<Notification>? PostNotification { protected get; set; }
@@ -112,6 +113,24 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
public event Action? Disconnecting;
public event Action<MultiplayerCountdown>? CountdownStarted;
public event Action<MultiplayerCountdown>? CountdownStopped;
public event Action<MatchServerEvent>? MatchEvent;
public event Action<MultiplayerRoomUser, MultiplayerUserState>? UserStateChanged;
public event Action? MatchmakingQueueJoined;
public event Action? MatchmakingQueueLeft;
public event Action? MatchmakingRoomInvited;
public event Action<long, string>? MatchmakingRoomReady;
public event Action<MatchmakingLobbyStatus>? MatchmakingLobbyStatusChanged;
public event Action<MatchmakingQueueStatus>? MatchmakingQueueStatusChanged;
public event Action<int, long>? MatchmakingItemSelected;
public event Action<int, long>? MatchmakingItemDeselected;
public event Action<MatchRoomState>? MatchRoomStateChanged;
/// <summary>
/// Whether the <see cref="MultiplayerClient"/> is currently connected.
/// This is NOT thread safe and usage should be scheduled.
@@ -179,9 +198,13 @@ namespace osu.Game.Online.Multiplayer
{
IsConnected.BindValueChanged(connected => Scheduler.Add(() =>
{
// clean up local room state on server disconnect.
if (!connected.NewValue && Room != null)
LeaveRoom();
if (!connected.NewValue)
{
if (Room != null)
LeaveRoom();
MatchmakingQueueLeft?.Invoke();
}
}));
}
@@ -254,6 +277,9 @@ namespace osu.Game.Online.Multiplayer
Room = joinedRoom;
APIRoom = apiRoom;
while (pendingRequests.TryDequeue(out Action? action))
action();
APIRoom.RoomID = joinedRoom.RoomID;
APIRoom.ChannelId = joinedRoom.ChannelID;
APIRoom.Host = joinedRoom.Host?.User;
@@ -640,6 +666,7 @@ namespace osu.Game.Online.Multiplayer
user.State = state;
updateUserPlayingState(userId, state);
UserStateChanged?.Invoke(user, state);
RoomUpdated?.Invoke();
});
@@ -672,13 +699,14 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(Room != null);
Room.MatchState = state;
MatchRoomStateChanged?.Invoke(state);
RoomUpdated?.Invoke();
});
return Task.CompletedTask;
}
public Task MatchEvent(MatchServerEvent e)
Task IMultiplayerClient.MatchEvent(MatchServerEvent e)
{
handleRoomRequest(() =>
{
@@ -688,6 +716,7 @@ namespace osu.Game.Online.Multiplayer
{
case CountdownStartedEvent countdownStartedEvent:
Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown);
CountdownStarted?.Invoke(countdownStartedEvent.Countdown);
switch (countdownStartedEvent.Countdown)
{
@@ -700,11 +729,17 @@ namespace osu.Game.Online.Multiplayer
case CountdownStoppedEvent countdownStoppedEvent:
MultiplayerCountdown? countdown = Room.ActiveCountdowns.FirstOrDefault(countdown => countdown.ID == countdownStoppedEvent.ID);
if (countdown != null)
{
Room.ActiveCountdowns.Remove(countdown);
CountdownStopped?.Invoke(countdown);
}
break;
}
MatchEvent?.Invoke(e);
RoomUpdated?.Invoke();
});
@@ -1001,6 +1036,82 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingQueueJoined()
{
Scheduler.Add(() => MatchmakingQueueJoined?.Invoke());
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingQueueLeft()
{
Scheduler.Add(() => MatchmakingQueueLeft?.Invoke());
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingRoomInvited()
{
Scheduler.Add(() => MatchmakingRoomInvited?.Invoke());
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingRoomReady(long roomId, string password)
{
Scheduler.Add(() => MatchmakingRoomReady?.Invoke(roomId, password));
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status)
{
Scheduler.Add(() => MatchmakingLobbyStatusChanged?.Invoke(status));
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingQueueStatusChanged(MatchmakingQueueStatus status)
{
Scheduler.Add(() => MatchmakingQueueStatusChanged?.Invoke(status));
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingItemSelected(int userId, long playlistItemId)
{
Scheduler.Add(() =>
{
MatchmakingItemSelected?.Invoke(userId, playlistItemId);
RoomUpdated?.Invoke();
});
return Task.CompletedTask;
}
Task IMatchmakingClient.MatchmakingItemDeselected(int userId, long playlistItemId)
{
Scheduler.Add(() =>
{
MatchmakingItemDeselected?.Invoke(userId, playlistItemId);
RoomUpdated?.Invoke();
});
return Task.CompletedTask;
}
public abstract Task<MatchmakingPool[]> GetMatchmakingPools();
public abstract Task MatchmakingJoinLobby();
public abstract Task MatchmakingLeaveLobby();
public abstract Task MatchmakingJoinQueue(int poolId);
public abstract Task MatchmakingLeaveQueue();
public abstract Task MatchmakingAcceptInvitation();
public abstract Task MatchmakingDeclineInvitation();
public abstract Task MatchmakingToggleSelection(long playlistItemId);
public abstract Task MatchmakingSkipToNextStage();
private partial class MultiplayerInvitationNotification : UserAvatarNotification
{
protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times;
@@ -14,6 +14,7 @@ using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Notifications;
using osu.Game.Localisation;
using osu.Game.Online.Matchmaking;
namespace osu.Game.Online.Multiplayer
{
@@ -70,6 +71,15 @@ namespace osu.Game.Online.Multiplayer
connection.On<long>(nameof(IMultiplayerClient.PlaylistItemRemoved), ((IMultiplayerClient)this).PlaylistItemRemoved);
connection.On<MultiplayerPlaylistItem>(nameof(IMultiplayerClient.PlaylistItemChanged), ((IMultiplayerClient)this).PlaylistItemChanged);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested);
connection.On(nameof(IMatchmakingClient.MatchmakingQueueJoined), ((IMatchmakingClient)this).MatchmakingQueueJoined);
connection.On(nameof(IMatchmakingClient.MatchmakingQueueLeft), ((IMatchmakingClient)this).MatchmakingQueueLeft);
connection.On(nameof(IMatchmakingClient.MatchmakingRoomInvited), ((IMatchmakingClient)this).MatchmakingRoomInvited);
connection.On<long, string>(nameof(IMatchmakingClient.MatchmakingRoomReady), ((IMatchmakingClient)this).MatchmakingRoomReady);
connection.On<MatchmakingLobbyStatus>(nameof(IMatchmakingClient.MatchmakingLobbyStatusChanged), ((IMatchmakingClient)this).MatchmakingLobbyStatusChanged);
connection.On<MatchmakingQueueStatus>(nameof(IMatchmakingClient.MatchmakingQueueStatusChanged), ((IMatchmakingClient)this).MatchmakingQueueStatusChanged);
connection.On<int, long>(nameof(IMatchmakingClient.MatchmakingItemSelected), ((IMatchmakingClient)this).MatchmakingItemSelected);
connection.On<int, long>(nameof(IMatchmakingClient.MatchmakingItemDeselected), ((IMatchmakingClient)this).MatchmakingItemDeselected);
};
IsConnected.BindTo(connector.IsConnected);
@@ -310,6 +320,87 @@ namespace osu.Game.Online.Multiplayer
return connector.Disconnect();
}
public override Task<MatchmakingPool[]> GetMatchmakingPools()
{
if (!IsConnected.Value)
return Task.FromResult(Array.Empty<MatchmakingPool>());
Debug.Assert(connection != null);
return connection.InvokeAsync<MatchmakingPool[]>(nameof(IMatchmakingServer.GetMatchmakingPools));
}
public override Task MatchmakingJoinLobby()
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinLobby));
}
public override Task MatchmakingLeaveLobby()
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingLeaveLobby));
}
public override Task MatchmakingJoinQueue(int poolId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinQueue), poolId);
}
public override Task MatchmakingLeaveQueue()
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingLeaveQueue));
}
public override Task MatchmakingAcceptInvitation()
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingAcceptInvitation));
}
public override Task MatchmakingDeclineInvitation()
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingDeclineInvitation));
}
public override Task MatchmakingToggleSelection(long playlistItemId)
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingToggleSelection), playlistItemId);
}
public override Task MatchmakingSkipToNextStage()
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingSkipToNextStage));
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
+1
View File
@@ -358,6 +358,7 @@ namespace osu.Game.Online.Rooms
public Room(MultiplayerRoom room)
{
RoomID = room.RoomID;
ChannelId = room.ChannelID;
Name = room.Settings.Name;
Password = room.Settings.Password;
Type = room.Settings.MatchType;
+4 -1
View File
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
@@ -53,7 +54,9 @@ namespace osu.Game.Online
(typeof(MatchmakingQueueStatus.MatchFound), typeof(MatchmakingQueueStatus)),
(typeof(MatchmakingQueueStatus.JoiningMatch), typeof(MatchmakingQueueStatus)),
(typeof(MatchmakingRoomState), typeof(MatchRoomState)),
(typeof(MatchmakingStageCountdown), typeof(MultiplayerCountdown))
(typeof(MatchmakingStageCountdown), typeof(MultiplayerCountdown)),
(typeof(MatchmakingAvatarActionRequest), typeof(MatchUserRequest)),
(typeof(MatchmakingAvatarActionEvent), typeof(MatchServerEvent)),
};
}
}
+3
View File
@@ -65,6 +65,7 @@ using osu.Game.Screens.Edit;
using osu.Game.Screens.Footer;
using osu.Game.Screens.Menu;
using osu.Game.Screens.OnlinePlay.DailyChallenge;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play;
@@ -79,6 +80,7 @@ using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
using Sentry;
using IntroScreen = osu.Game.Screens.Menu.IntroScreen;
using MatchType = osu.Game.Online.Rooms.MatchType;
namespace osu.Game
@@ -1270,6 +1272,7 @@ namespace osu.Game
loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add);
loadComponentSingleFile<BeatmapStore>(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true);
loadComponentSingleFile(new QueueController(), Add, true);
Add(externalLinkOpener = new ExternalLinkOpener());
Add(new MusicKeyBindingHandler());
-2
View File
@@ -467,8 +467,6 @@ namespace osu.Game
protected virtual void InitialiseFonts()
{
AddFont(Resources, @"Fonts/osuFont");
AddFont(Resources, @"Fonts/Torus/Torus-Regular");
AddFont(Resources, @"Fonts/Torus/Torus-Light");
AddFont(Resources, @"Fonts/Torus/Torus-SemiBold");
+108 -37
View File
@@ -13,6 +13,7 @@ using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Settings;
using osu.Game.Resources.Localisation.Web;
using osuTK;
@@ -21,11 +22,11 @@ namespace osu.Game.Overlays.Login
{
public partial class SecondFactorAuthForm : Container
{
private OsuTextBox codeTextBox = null!;
private LinkFlowContainer explainText = null!;
private ErrorTextFlowContainer errorText = null!;
private LoadingLayer loading = null!;
private FillFlowContainer contentFlow = null!;
private OsuTextBox codeTextBox = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
@@ -36,6 +37,8 @@ namespace osu.Game.Overlays.Login
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS };
Children = new Drawable[]
{
new FillFlowContainer
@@ -46,46 +49,18 @@ namespace osu.Game.Overlays.Login
Spacing = new Vector2(0, SettingsSection.ITEM_SPACING),
Children = new Drawable[]
{
new FillFlowContainer
contentFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS },
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, SettingsSection.ITEM_SPACING),
Children = new Drawable[]
{
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Text = "An email has been sent to you with a verification code. Enter the code.",
},
codeTextBox = new OsuTextBox
{
InputProperties = new TextInputProperties(TextInputType.Code),
PlaceholderText = "Enter code",
RelativeSizeAxes = Axes.X,
TabbableContentContainer = this,
},
explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
errorText = new ErrorTextFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
},
},
},
new LinkFlowContainer
errorText = new ErrorTextFlowContainer
{
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS },
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
},
}
},
@@ -95,6 +70,56 @@ namespace osu.Game.Overlays.Login
}
};
if (api.LastLoginError?.Message is string error)
{
errorText.Alpha = 1;
errorText.AddErrors(new[] { error });
}
showContent(api.SessionVerificationMethod!.Value);
}
private void showContent(SessionVerificationMethod sessionVerificationMethod)
{
switch (sessionVerificationMethod)
{
case SessionVerificationMethod.EmailMessage:
showEmailVerification();
break;
case SessionVerificationMethod.TimedOneTimePassword:
showTotpVerification();
break;
}
}
private void showEmailVerification()
{
LinkFlowContainer explainText;
contentFlow.Clear();
contentFlow.AddRange(new Drawable[]
{
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Text = "An email has been sent to you with a verification code. Enter the code.",
},
codeTextBox = new OsuTextBox
{
InputProperties = new TextInputProperties(TextInputType.Code),
PlaceholderText = "Enter code",
RelativeSizeAxes = Axes.X,
TabbableContentContainer = this,
},
explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
});
explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam);
// We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something).
explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the ");
@@ -131,12 +156,58 @@ namespace osu.Game.Overlays.Login
codeTextBox.Current.Disabled = true;
}
});
}
if (api.LastLoginError?.Message is string error)
private void showTotpVerification()
{
LinkFlowContainer explainText;
contentFlow.Clear();
contentFlow.AddRange(new Drawable[]
{
errorText.Alpha = 1;
errorText.AddErrors(new[] { error });
}
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Text = "Please enter the code from your authenticator app.",
},
codeTextBox = new OsuNumberBox
{
InputProperties = new TextInputProperties(TextInputType.NumericalPassword),
PlaceholderText = "Enter code",
RelativeSizeAxes = Axes.X,
TabbableContentContainer = this,
},
explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
});
// We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something).
explainText.AddParagraph("If you can't access your app, ");
explainText.AddLink("you can verify using email instead", () =>
{
var fallbackRequest = new VerificationMailFallbackRequest();
fallbackRequest.Success += showEmailVerification;
fallbackRequest.Failure += ex => errorText.Text = ex.Message;
Task.Run(() => api.Perform(fallbackRequest));
});
explainText.AddText(". You can also ");
explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); });
explainText.AddText(".");
codeTextBox.Current.BindValueChanged(code =>
{
string trimmedCode = code.NewValue.Trim();
if (trimmedCode.Length == 6)
{
api.AuthenticateSecondFactor(trimmedCode);
codeTextBox.Current.Disabled = true;
}
});
}
public override bool AcceptsFocus => true;
@@ -31,6 +31,8 @@ namespace osu.Game.Overlays.Profile.Header.Components
{
Child = new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
FillMode = FillMode.Fit,
RelativeSizeAxes = Axes.Both,
Texture = textures.Get(badge.ImageUrl),
@@ -262,14 +262,6 @@ namespace osu.Game.Rulesets.Edit
.Select(t => new HitObjectCompositionToolButton(t, () => toolSelected(t)))
.ToList();
foreach (var item in toolboxCollection.Items)
{
item.Selected.DisabledChanged += isDisabled =>
{
item.TooltipText = isDisabled ? "Add at least one timing point first!" : ((HitObjectCompositionToolButton)item).TooltipText;
};
}
togglesCollection.AddRange(CreateTernaryButtons().ToArray());
sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates);
@@ -16,7 +16,10 @@ namespace osu.Game.Rulesets.Edit
{
Tool = tool;
TooltipText = tool.TooltipText;
Selected.BindDisabledChanged(isDisabled =>
{
TooltipText = isDisabled ? "Add at least one timing point first!" : Tool.TooltipText;
}, true);
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Mods
get
{
if (!SpeedChange.IsDefault)
yield return ("Speed change", $"{SpeedChange.Value:N2}x");
yield return ("Speed change", FormattableString.Invariant($@"{SpeedChange.Value:N2}x"));
}
}
-10
View File
@@ -1,7 +1,6 @@
// 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.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
@@ -10,40 +9,31 @@ namespace osu.Game.Scoring
public enum ScoreRank
{
// TODO: Localisable?
[Description(@"F")]
F = -1,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankD))]
[Description(@"D")]
D,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankC))]
[Description(@"C")]
C,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankB))]
[Description(@"B")]
B,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankA))]
[Description(@"A")]
A,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankS))]
[Description(@"S")]
S,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankSH))]
[Description(@"S+")]
// ReSharper disable once InconsistentNaming
SH,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankX))]
[Description(@"SS")]
X,
[LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankXH))]
[Description(@"SS+")]
// ReSharper disable once InconsistentNaming
XH,
}
@@ -263,6 +263,7 @@ namespace osu.Game.Screens.Edit.Submission
iconContainer.Colour = colours.Red1;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
errorSample?.Play();
progressSampleChannel?.Stop();
break;
case StageStatusType.Canceled:
@@ -274,6 +275,7 @@ namespace osu.Game.Screens.Edit.Submission
iconContainer.Colour = colours.Gray8;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
cancelSample?.Play();
progressSampleChannel?.Stop();
break;
}
}
+49 -5
View File
@@ -13,6 +13,7 @@ using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
@@ -46,6 +47,7 @@ namespace osu.Game.Screens.Menu
public Action? OnSolo;
public Action? OnSettings;
public Action? OnMultiplayer;
public Action? OnMatchmaking;
public Action? OnPlaylists;
public Action<Room>? OnDailyChallenge;
@@ -84,6 +86,7 @@ namespace osu.Game.Screens.Menu
private readonly List<MainMenuButton> buttonsTopLevel = new List<MainMenuButton>();
private readonly List<MainMenuButton> buttonsPlay = new List<MainMenuButton>();
private readonly List<MainMenuButton> buttonsMulti = new List<MainMenuButton>();
private readonly List<MainMenuButton> buttonsEdit = new List<MainMenuButton>();
private Sample? sampleBackToLogo;
@@ -109,7 +112,19 @@ namespace osu.Game.Screens.Menu
{
Padding = new MarginPadding { Right = WEDGE_WIDTH },
},
backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), (_, _) => State = ButtonSystemState.TopLevel)
backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), (_, _) =>
{
switch (State)
{
case ButtonSystemState.Multi:
State = ButtonSystemState.Play;
break;
default:
State = ButtonSystemState.TopLevel;
break;
}
})
{
Padding = new MarginPadding { Right = WEDGE_WIDTH },
VisibleStateMin = ButtonSystemState.Play,
@@ -137,28 +152,39 @@ namespace osu.Game.Screens.Menu
{
Padding = new MarginPadding { Left = WEDGE_WIDTH },
});
buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), onMultiplayer, Key.M));
buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), (_, _) => State = ButtonSystemState.Multi, Key.M));
buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Tournament, new Color4(94, 63, 186, 255), onPlaylists, Key.L));
buttonsPlay.Add(new DailyChallengeButton(@"button-daily-select", new Color4(94, 63, 186, 255), onDailyChallenge, Key.D));
buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play);
buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, Key.E)
buttonsMulti.Add(new MainMenuButton("lounge", @"button-default-select", FontAwesome.Solid.Couch, new Color4(94, 63, 186, 255), onMultiplayer, Key.L, Key.M)
{
Padding = new MarginPadding { Left = WEDGE_WIDTH }
});
buttonsMulti.Add(new MainMenuButton("quick play", @"button-default-select", FontAwesome.Solid.Bolt, new Color4(94, 63, 186, 255), onMatchmaking, Key.Q));
buttonsMulti.ForEach(b => b.VisibleState = ButtonSystemState.Multi);
buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B,
Key.E)
{
Padding = new MarginPadding { Left = WEDGE_WIDTH },
});
buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), (_, _) => OnEditSkin?.Invoke(), Key.S));
buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit);
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, Key.L)
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M,
Key.L)
{
Padding = new MarginPadding { Left = WEDGE_WIDTH },
});
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), (_, _) => State = ButtonSystemState.Edit, Key.E));
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, Key.D));
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B,
Key.D));
if (host.CanExit)
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), (_, e) => OnExit?.Invoke(e), Key.Q));
buttonArea.AddRange(buttonsMulti);
buttonArea.AddRange(buttonsPlay);
buttonArea.AddRange(buttonsEdit);
buttonArea.AddRange(buttonsTopLevel);
@@ -191,6 +217,17 @@ namespace osu.Game.Screens.Menu
OnMultiplayer?.Invoke();
}
private void onMatchmaking(MainMenuButton mainMenuButton, UIEvent uiEvent)
{
if (api.State.Value != APIState.Online)
{
loginOverlay?.Show();
return;
}
OnMatchmaking?.Invoke();
}
private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent)
{
if (api.State.Value != APIState.Online)
@@ -315,6 +352,7 @@ namespace osu.Game.Screens.Menu
case ButtonSystemState.Edit:
case ButtonSystemState.Play:
case ButtonSystemState.Multi:
StopSamplePlayback();
backButton.TriggerClick();
return true;
@@ -327,6 +365,7 @@ namespace osu.Game.Screens.Menu
public void StopSamplePlayback()
{
buttonsPlay.ForEach(button => button.StopSamplePlayback());
buttonsMulti.ForEach(button => button.StopSamplePlayback());
buttonsTopLevel.ForEach(button => button.StopSamplePlayback());
logo?.StopSamplePlayback();
}
@@ -350,6 +389,10 @@ namespace osu.Game.Screens.Menu
buttonsPlay.First().TriggerClick();
return false;
case ButtonSystemState.Multi:
buttonsPlay.First().TriggerClick();
return false;
case ButtonSystemState.Edit:
buttonsEdit.First().TriggerClick();
return false;
@@ -471,6 +514,7 @@ namespace osu.Game.Screens.Menu
Initial,
TopLevel,
Play,
Multi,
Edit,
EnteringMode,
}
+3
View File
@@ -159,6 +159,7 @@ namespace osu.Game.Screens.Menu
},
OnSolo = loadSongSelect,
OnMultiplayer = () => this.Push(new Multiplayer()),
OnMatchmaking = joinOrLeaveMatchmakingQueue,
OnPlaylists = () => this.Push(new Playlists()),
OnDailyChallenge = room =>
{
@@ -481,6 +482,8 @@ namespace osu.Game.Screens.Menu
private void loadSongSelect() => this.Push(new SoloSongSelect());
private void joinOrLeaveMatchmakingQueue() => this.Push(new OnlinePlay.Matchmaking.Intro.IntroScreen());
private partial class MobileDisclaimerDialog : PopupDialog
{
public MobileDisclaimerDialog(Action confirmed)
@@ -0,0 +1,266 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Screens;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro
{
/// <summary>
/// A brief intro animation that introduces matchmaking to the user.
/// </summary>
public partial class IntroScreen : OsuScreen
{
public override bool DisallowExternalBeatmapRulesetChanges => false;
public override bool? ApplyModTrackAdjustments => true;
public override bool ShowFooter => true;
private Container introContent = null!;
private Container titleContainer = null!;
private bool animationBegan;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum);
[Resolved]
private MusicController musicController { get; set; } = null!;
private Sample? dateWindupSample;
private Sample? dateImpactSample;
private Sample? beatmapWindupSample;
private Sample? beatmapImpactSample;
private SampleChannel? dateWindupChannel;
private SampleChannel? dateImpactChannel;
private SampleChannel? beatmapWindupChannel;
private SampleChannel? beatmapImpactChannel;
private IDisposable? duckOperation;
protected override BackgroundScreen CreateBackground() => new MatchmakingIntroBackgroundScreen(colourProvider);
public IntroScreen()
{
ValidForResume = false;
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
InternalChildren = new Drawable[]
{
introContent = new Container
{
Alpha = 0f,
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Shear = OsuGame.SHEAR,
Children = new Drawable[]
{
titleContainer = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
CornerRadius = 10f,
Masking = true,
X = 10,
Children = new Drawable[]
{
new Box
{
Colour = colourProvider.Background3,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Quick Play",
Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f },
Shear = -OsuGame.SHEAR,
Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate),
},
}
},
}
},
}
}
};
dateWindupSample = audio.Samples.Get(@"DailyChallenge/date-windup");
dateImpactSample = audio.Samples.Get(@"DailyChallenge/date-impact");
beatmapWindupSample = audio.Samples.Get(@"DailyChallenge/beatmap-windup");
beatmapImpactSample = audio.Samples.Get(@"DailyChallenge/beatmap-impact");
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
this.FadeInFromZero(400, Easing.OutQuint);
updateAnimationState();
playDateWindupSample();
}
public override void OnSuspending(ScreenTransitionEvent e)
{
duckOperation?.Dispose();
this.FadeOut(800, Easing.OutQuint);
base.OnSuspending(e);
}
public override bool OnExiting(ScreenExitEvent e)
{
if (base.OnExiting(e))
return true;
duckOperation?.Dispose();
return false;
}
private void updateAnimationState()
{
if (animationBegan)
return;
beginAnimation();
animationBegan = true;
}
private void beginAnimation()
{
using (BeginDelayedSequence(200))
{
introContent.Show();
titleContainer
.ScaleTo(2)
.Then()
.ScaleTo(1, 400, Easing.In);
using (BeginDelayedSequence(150))
{
Schedule(() =>
{
playDateImpactSample();
playBeatmapWindupSample();
duckOperation?.Dispose();
duckOperation = musicController.Duck(new DuckParameters
{
RestoreDuration = 1500f,
});
});
}
using (BeginDelayedSequence(1000))
{
using (BeginDelayedSequence(100))
{
titleContainer
.ScaleTo(0.4f, 400, Easing.In)
.FadeOut(500, Easing.OutQuint);
}
using (BeginDelayedSequence(240))
{
Schedule(() =>
{
if (this.IsCurrentScreen())
this.Push(new ScreenQueue());
});
}
}
}
}
private void playDateWindupSample()
{
dateWindupChannel = dateWindupSample?.GetChannel();
dateWindupChannel?.Play();
}
private void playDateImpactSample()
{
dateImpactChannel = dateImpactSample?.GetChannel();
dateImpactChannel?.Play();
}
private void playBeatmapWindupSample()
{
beatmapWindupChannel = beatmapWindupSample?.GetChannel();
beatmapWindupChannel?.Play();
}
private void playBeatmapImpactSample()
{
beatmapImpactChannel = beatmapImpactSample?.GetChannel();
beatmapImpactChannel?.Play();
}
protected override void Dispose(bool isDisposing)
{
resetAudio();
base.Dispose(isDisposing);
}
private void resetAudio()
{
dateWindupChannel?.Stop();
dateImpactChannel?.Stop();
beatmapWindupChannel?.Stop();
beatmapImpactChannel?.Stop();
duckOperation?.Dispose();
}
private partial class MatchmakingIntroBackgroundScreen : RoomBackgroundScreen
{
private readonly OverlayColourProvider colourProvider;
public MatchmakingIntroBackgroundScreen(OverlayColourProvider colourProvider)
: base(null)
{
this.colourProvider = colourProvider;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(new Box
{
Depth = float.MinValue,
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5.Opacity(0.6f),
});
}
}
}
}
@@ -0,0 +1,354 @@
// 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 System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Beatmaps.Drawables.Cards.Statistics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapSet;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect
{
public partial class BeatmapCardMatchmaking : BeatmapCard
{
private readonly APIBeatmap beatmap;
protected override Drawable IdleContent => idleBottomContent;
protected override Drawable DownloadInProgressContent => downloadProgressBar;
public const float HEIGHT = 80;
[Cached]
private readonly BeatmapCardContent content;
private BeatmapCardThumbnail thumbnail = null!;
private CollapsibleButtonContainer buttonContainer = null!;
private FillFlowContainer<BeatmapCardStatistic> statisticsContainer = null!;
private FillFlowContainer idleBottomContent = null!;
private BeatmapCardDownloadProgressBar downloadProgressBar = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public BeatmapCardMatchmaking(APIBeatmap beatmap)
: base(beatmap.BeatmapSet!, false)
{
this.beatmap = beatmap;
content = new BeatmapCardContent(HEIGHT);
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Width = WIDTH;
Height = HEIGHT;
FillFlowContainer leftIconArea = null!;
FillFlowContainer titleBadgeArea = null!;
GridContainer artistContainer = null!;
Child = content.With(c =>
{
c.MainContent = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
thumbnail = new BeatmapCardThumbnail(BeatmapSet, BeatmapSet, keepLoaded: true)
{
Name = @"Left (icon) area",
Size = new Vector2(HEIGHT),
Padding = new MarginPadding { Right = CORNER_RADIUS },
Child = leftIconArea = new FillFlowContainer
{
Margin = new MarginPadding(4),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(1)
}
},
buttonContainer = new CollapsibleButtonContainer(BeatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true)
{
X = HEIGHT - CORNER_RADIUS,
Width = WIDTH - HEIGHT + CORNER_RADIUS,
FavouriteState = { BindTarget = FavouriteState },
ButtonsCollapsedWidth = 0,
ButtonsExpandedWidth = 24,
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize),
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new Drawable[]
{
new TruncatingSpriteText
{
Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title),
Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
},
titleBadgeArea = new FillFlowContainer
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
}
}
}
},
artistContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new TruncatingSpriteText
{
Text = createArtistText(),
Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
},
Empty()
},
}
},
new LinkFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold);
}).With(d =>
{
d.AutoSizeAxes = Axes.Both;
d.Margin = new MarginPadding { Top = 1 };
d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
d.AddUserLink(BeatmapSet.Author);
}),
}
},
new Container
{
Name = @"Bottom content",
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Children = new Drawable[]
{
idleBottomContent = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 2),
AlwaysPresent = true,
Children = new Drawable[]
{
statisticsContainer = new FillFlowContainer<BeatmapCardStatistic>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(8, 0),
Alpha = 0,
AlwaysPresent = true,
ChildrenEnumerable = createStatistics()
},
new Container
{
Masking = true,
CornerRadius = CORNER_RADIUS,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
new Box
{
Colour = colours.ForStarDifficulty(beatmap.StarRating).Darken(0.8f),
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
Padding = new MarginPadding(2),
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(4, 0),
Children = new Drawable[]
{
new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small, animated: true)
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Scale = new Vector2(0.875f),
},
new TruncatingSpriteText
{
Text = beatmap.DifficultyName,
Font = OsuFont.Style.Caption2.With(weight: FontWeight.Bold),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
}
},
}
},
}
},
downloadProgressBar = new BeatmapCardDownloadProgressBar
{
RelativeSizeAxes = Axes.X,
Height = 5,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
State = { BindTarget = DownloadTracker.State },
Progress = { BindTarget = DownloadTracker.Progress }
}
}
}
}
}
}
};
c.Expanded.BindTarget = Expanded;
});
if (BeatmapSet.HasVideo)
leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) });
if (BeatmapSet.HasStoryboard)
leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) });
if (BeatmapSet.FeaturedInSpotlight)
{
titleBadgeArea.Add(new SpotlightBeatmapBadge
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 4 }
});
}
if (BeatmapSet.HasExplicitContent)
{
titleBadgeArea.Add(new ExplicitContentBeatmapBadge
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 4 }
});
}
if (BeatmapSet.TrackId != null)
{
artistContainer.Content[0][1] = new FeaturedArtistBeatmapBadge
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 4 }
};
}
}
private LocalisableString createArtistText()
{
var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist);
return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist);
}
private IEnumerable<BeatmapCardStatistic> createStatistics()
{
var hypesStatistic = HypesStatistic.CreateFor(BeatmapSet);
if (hypesStatistic != null)
yield return hypesStatistic;
var nominationsStatistic = NominationsStatistic.CreateFor(BeatmapSet);
if (nominationsStatistic != null)
yield return nominationsStatistic;
yield return new FavouritesStatistic(BeatmapSet) { Current = FavouriteState };
yield return new PlayCountStatistic(BeatmapSet);
var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet);
if (dateStatistic != null)
yield return dateStatistic;
}
protected override void UpdateState()
{
base.UpdateState();
bool showDetails = IsHovered;
buttonContainer.ShowDetails.Value = showDetails;
thumbnail.Dimmed.Value = showDetails;
statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
}
public override MenuItem[] ContextMenuItems
{
get
{
var items = base.ContextMenuItems.ToList();
foreach (var button in buttonContainer.Buttons)
{
if (button.Enabled.Value)
items.Add(new OsuMenuItem(button.TooltipText.ToSentence(), MenuItemType.Standard, () => button.TriggerClick()));
}
return items.ToArray();
}
}
}
}
@@ -0,0 +1,377 @@
// 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.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.Toolkit.HighPerformance;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Transforms;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect
{
public partial class BeatmapSelectGrid : CompositeDrawable
{
public const double ARRANGE_DELAY = 200;
private const double hide_duration = 800;
private const double arrange_duration = 1000;
private const double roll_duration = 4000;
private const double present_beatmap_delay = 1200;
private const float panel_spacing = 4;
public event Action<MultiplayerPlaylistItem>? ItemSelected;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private readonly Dictionary<long, BeatmapSelectPanel> panelLookup = new Dictionary<long, BeatmapSelectPanel>();
private readonly PanelGridContainer panelGridContainer;
private readonly Container<BeatmapSelectPanel> rollContainer;
private readonly OsuScrollContainer scroll;
private bool allowSelection = true;
private readonly Sample?[] spinSamples = new Sample?[5];
private static readonly int[] spin_sample_sequence = [0, 1, 2, 3, 4, 2, 3, 4];
private Sample? resultSample;
private Sample? swooshSample;
private double? lastSamplePlayback;
public BeatmapSelectGrid()
{
InternalChildren = new Drawable[]
{
scroll = new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
ScrollbarVisible = false,
Child = panelGridContainer = new PanelGridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding(20),
Spacing = new Vector2(panel_spacing)
},
},
rollContainer = new Container<BeatmapSelectPanel>
{
RelativeSizeAxes = Axes.Both,
Masking = true,
},
};
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
for (int i = 0; i < spinSamples.Length; i++)
spinSamples[i] = audio.Samples.Get($@"Multiplayer/Matchmaking/Selection/roulette-{i}");
resultSample = audio.Samples.Get(@"Multiplayer/Matchmaking/Selection/roulette-result");
swooshSample = audio.Samples.Get(@"SongSelect/options-pop-out");
}
protected override void LoadComplete()
{
base.LoadComplete();
const double enter_duration = 500;
// the scroll container has a 1 frame delay until it receives the correct height for the scrollable area which leads to the scrollbar resizing awkwardly
// if we wait until the panels have entered we get to avoid having to see that and the scrollbar it will appear synchronized with the rest of the content as a bonus
Scheduler.AddDelayed(() => scroll.ScrollbarVisible = true, enter_duration);
SchedulerAfterChildren.Add(() =>
{
foreach (var panel in panelGridContainer)
{
double delay = panel.Y / 3;
panel.FadeInAndEnterFromBelow(duration: enter_duration, delay: delay);
}
});
}
public void AddItem(MultiplayerPlaylistItem item)
{
var panel = panelLookup[item.ID] = new BeatmapSelectPanel(item)
{
AllowSelection = allowSelection,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Action = ItemSelected,
};
panelGridContainer.Add(panel);
panelGridContainer.SetLayoutPosition(panel, (float)item.StarRating);
}
public void RemoveItem(long id)
{
if (!panelLookup.Remove(id, out var panel))
return;
panel.Expire();
}
public void SetUserSelection(APIUser user, long itemId, bool selected)
{
if (!panelLookup.TryGetValue(itemId, out var panel))
return;
if (selected)
panel.AddUser(user, user.Equals(api.LocalUser.Value));
else
panel.RemoveUser(user);
}
public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long finalItemId)
{
Debug.Assert(candidateItemIds.Length >= 1);
Debug.Assert(candidateItemIds.Contains(finalItemId));
Debug.Assert(panelLookup.ContainsKey(finalItemId));
Debug.Assert(candidateItemIds.All(id => panelLookup.ContainsKey(id)));
allowSelection = false;
TransferCandidatePanelsToRollContainer(candidateItemIds);
if (candidateItemIds.Length == 1)
{
this.Delay(ARRANGE_DELAY)
.Schedule(() => ArrangeItemsForRollAnimation())
.Delay(arrange_duration + present_beatmap_delay)
.Schedule(() => PresentUnanimouslyChosenBeatmap(finalItemId));
}
else
{
this.Delay(ARRANGE_DELAY)
.Schedule(() => ArrangeItemsForRollAnimation())
.Delay(arrange_duration)
.Schedule(() => PlayRollAnimation(finalItemId, roll_duration))
.Delay(roll_duration + present_beatmap_delay)
.Schedule(() => PresentRolledBeatmap(finalItemId));
}
}
internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, double duration = hide_duration)
{
scroll.ScrollbarVisible = false;
panelGridContainer.LayoutDisabled = true;
var rng = new Random();
var remainingPanels = new List<BeatmapSelectPanel>();
foreach (var panel in panelGridContainer.Children.ToArray())
{
panel.AllowSelection = false;
if (!candidateItemIds.Contains(panel.Item.ID))
{
panel.PopOutAndExpire(duration: duration / 2, delay: rng.NextDouble() * duration / 2);
continue;
}
remainingPanels.Add(panel);
}
rng.Shuffle(remainingPanels.AsSpan());
foreach (var panel in remainingPanels)
{
var position = panel.ScreenSpaceDrawQuad.Centre;
panelGridContainer.Remove(panel, false);
panel.Anchor = panel.Origin = Anchor.Centre;
panel.Position = rollContainer.ToLocalSpace(position) - rollContainer.ChildSize / 2;
rollContainer.Add(panel);
}
}
internal void ArrangeItemsForRollAnimation(double duration = arrange_duration, double stagger = 30)
{
var positions = calculateLayoutPositionsForRollAnimation(rollContainer.Children.Count);
Debug.Assert(positions.Length == rollContainer.Children.Count);
for (int i = 0; i < positions.Length; i++)
{
var panel = rollContainer.Children[i];
var position = positions[i] * (BeatmapSelectPanel.SIZE + new Vector2(panel_spacing));
panel.MoveTo(position, duration + stagger * i, new SplitEasingFunction(Easing.InCubic, Easing.OutExpo, 0.3f));
Scheduler.AddDelayed(() =>
{
var chan = swooshSample?.GetChannel();
if (chan == null) return;
chan.Frequency.Value = 1.25f - RNG.NextDouble(0.5f);
chan.Play();
}, stagger * i);
}
}
private static Vector2[] calculateLayoutPositionsForRollAnimation(int panelCount)
{
if (panelCount == 1)
return new[] { Vector2.Zero };
// goal is to get the positions arranged in clockwise order, with the top-left position being the first one
// to keep things simple the positions are first inserted in the order: right row, optional bottom center panel, left row backwards
// then the positions get shifted by 1 to move the top-left position into the first spot
bool hasCenterPanel = panelCount % 2 == 1;
int rowCount = (panelCount + 1) / 2;
int outerRowCount = hasCenterPanel ? rowCount - 1 : rowCount;
float yOffset = -(rowCount - 1f) / 2;
var positions = new Vector2[panelCount];
for (int row = 0; row < outerRowCount; row++)
{
positions[row] = new Vector2(0.5f, row + yOffset);
}
if (hasCenterPanel)
{
int centerIndex = panelCount / 2;
positions[centerIndex] = new Vector2(0, outerRowCount + yOffset);
}
for (int row = 0; row < outerRowCount; row++)
{
int index = positions.Length - 1 - row;
positions[index] = new Vector2(-0.5f, row + yOffset);
}
return positions.TakeLast(1).Concat(positions.SkipLast(1)).ToArray();
}
internal void PlayRollAnimation(long finalItem, double duration = roll_duration)
{
const int minimum_steps = 20;
int finalItemIndex = rollContainer.Children
.Select(it => it.Item.ID)
.ToImmutableList()
.IndexOf(finalItem);
Debug.Assert(finalItemIndex >= 0);
int numSteps = minimum_steps;
while ((numSteps - 1) % rollContainer.Children.Count != finalItemIndex)
numSteps++;
BeatmapSelectPanel? lastPanel = null;
for (int i = 0; i < numSteps; i++)
{
float progress = ((float)i) / (numSteps - 1);
double delay = Math.Pow(progress, 2.5) * duration;
var panel = rollContainer.Children[i % rollContainer.Children.Count];
int ii = i;
Scheduler.AddDelayed(() =>
{
lastPanel?.HideBorder();
panel.ShowBorder();
if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME)
{
int sequenceIdx = ii % spin_sample_sequence.Length;
spinSamples[spin_sample_sequence[sequenceIdx]]?.Play();
lastSamplePlayback = Time.Current;
}
lastPanel = panel;
}, delay);
}
}
internal void PresentRolledBeatmap(long finalItem)
{
Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == finalItem));
foreach (var panel in rollContainer.Children)
{
if (panel.Item.ID != finalItem)
{
panel.FadeOut(200);
panel.PopOutAndExpire(easing: Easing.InQuad);
continue;
}
// if we changed child depth without scheduling we'd change the order of the panels while iterating
Schedule(() =>
{
rollContainer.ChangeChildDepth(panel, float.MinValue);
panel.ShowChosenBorder();
panel.MoveTo(Vector2.Zero, 1000, Easing.OutExpo)
.ScaleTo(1.5f, 1000, Easing.OutExpo);
resultSample?.Play();
});
}
}
internal void PresentUnanimouslyChosenBeatmap(long finalItem)
{
// TODO: display special animation in this case
PresentRolledBeatmap(finalItem);
}
private partial class PanelGridContainer : FillFlowContainer<BeatmapSelectPanel>
{
public bool LayoutDisabled;
protected override IEnumerable<Vector2> ComputeLayoutPositions()
{
if (LayoutDisabled)
return FlowingChildren.Select(c => c.Position);
return base.ComputeLayoutPositions();
}
}
private readonly struct SplitEasingFunction(DefaultEasingFunction easeIn, DefaultEasingFunction easeOut, float ratio) : IEasingFunction
{
public SplitEasingFunction(Easing easeIn, Easing easeOut, float ratio = 0.5f)
: this(new DefaultEasingFunction(easeIn), new DefaultEasingFunction(easeOut), ratio)
{
}
public double ApplyEasing(double time)
{
if (time < ratio)
return easeIn.ApplyEasing(time / ratio) * ratio;
return double.Lerp(ratio, 1, easeOut.ApplyEasing((time - ratio) / (1 - ratio)));
}
}
}
}
@@ -0,0 +1,341 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Database;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect
{
public partial class BeatmapSelectPanel : Container
{
public static readonly Vector2 SIZE = new Vector2(BeatmapCard.WIDTH, BeatmapCardNormal.HEIGHT);
public bool AllowSelection { get; set; }
public readonly MultiplayerPlaylistItem Item;
public Action<MultiplayerPlaylistItem>? Action { private get; init; }
private const float border_width = 3;
private Container scaleContainer = null!;
private AvatarOverlay selectionOverlay = null!;
private Drawable lighting = null!;
private Container border = null!;
private Container mainContent = null!;
public override bool PropagatePositionalInputSubTree => AllowSelection;
public BeatmapSelectPanel(MultiplayerPlaylistItem item)
{
Item = item;
Size = SIZE;
}
[BackgroundDependencyLoader]
private void load(BeatmapLookupCache lookupCache, OverlayColourProvider colourProvider)
{
InternalChild = scaleContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new[]
{
mainContent = new Container
{
Masking = true,
CornerRadius = BeatmapCard.CORNER_RADIUS,
CornerExponent = 10,
RelativeSizeAxes = Axes.Both,
Children = new[]
{
lighting = new Box
{
Blending = BlendingParameters.Additive,
RelativeSizeAxes = Axes.Both,
Alpha = 0,
},
selectionOverlay = new AvatarOverlay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
}
}
},
border = new Container
{
Alpha = 0,
Masking = true,
CornerRadius = BeatmapCard.CORNER_RADIUS,
CornerExponent = 10,
Blending = BlendingParameters.Additive,
RelativeSizeAxes = Axes.Both,
BorderThickness = border_width,
BorderColour = colourProvider.Light1,
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Radius = 40,
Roundness = 300,
Colour = colourProvider.Light3.Opacity(0.1f),
},
Children = new Drawable[]
{
new Box
{
AlwaysPresent = true,
Alpha = 0,
Colour = Color4.Black,
RelativeSizeAxes = Axes.Both,
},
}
},
}
};
lookupCache.GetBeatmapAsync(Item.BeatmapID).ContinueWith(b => Schedule(() =>
{
var beatmap = b.GetResultSafely()!;
beatmap.StarRating = Item.StarRating;
mainContent.Add(new BeatmapCardMatchmaking(beatmap)
{
Depth = float.MaxValue,
Action = () => Action?.Invoke(Item),
});
}));
}
public bool AddUser(APIUser user, bool isOwnUser = false) => selectionOverlay.AddUser(user, isOwnUser);
public bool RemoveUser(APIUser user) => selectionOverlay.RemoveUser(user.Id);
protected override bool OnHover(HoverEvent e)
{
lighting.FadeTo(0.2f, 50)
.Then()
.FadeTo(0.1f, 300);
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
lighting.FadeOut(200);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (e.Button == MouseButton.Left)
{
scaleContainer.ScaleTo(0.95f, 400, Easing.OutExpo);
return true;
}
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
base.OnMouseUp(e);
if (e.Button == MouseButton.Left)
{
scaleContainer.ScaleTo(1f, 500, Easing.OutElasticHalf);
}
}
protected override bool OnClick(ClickEvent e)
{
lighting.FadeTo(0.5f, 50)
.Then()
.FadeTo(0.1f, 400);
// pass through to let the beatmap card handle actual click.
return false;
}
public void ShowChosenBorder()
{
border.FadeTo(1, 1000, Easing.OutQuint);
}
public void ShowBorder()
{
border.FadeTo(1, 80, Easing.OutQuint)
.Then()
.FadeTo(0.7f, 800, Easing.OutQuint);
}
public void HideBorder()
{
border.FadeOut(500, Easing.OutQuint);
}
public void FadeInAndEnterFromBelow(double duration = 500, double delay = 0, float distance = 200)
{
scaleContainer
.FadeOut()
.MoveToY(distance)
.Delay(delay)
.FadeIn(duration / 2)
.MoveToY(0, duration, Easing.OutExpo);
}
public void PopOutAndExpire(double duration = 400, double delay = 0, Easing easing = Easing.InCubic)
{
AllowSelection = false;
scaleContainer.Delay(delay)
.ScaleTo(0, duration, easing)
.FadeOut(duration);
this.Delay(delay + duration).FadeOut().Expire();
}
private partial class AvatarOverlay : CompositeDrawable
{
private readonly Container<SelectionAvatar> avatars;
private Sample? userAddedSample;
private double? lastSamplePlayback;
public AvatarOverlay()
{
AutoSizeAxes = Axes.Both;
InternalChild = avatars = new Container<SelectionAvatar>
{
AutoSizeAxes = Axes.X,
Height = SelectionAvatar.AVATAR_SIZE,
};
Padding = new MarginPadding(5);
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
userAddedSample = audio.Samples.Get(@"Multiplayer/player-ready");
}
public bool AddUser(APIUser user, bool isOwnUser)
{
if (avatars.Any(a => a.User.Id == user.Id))
return false;
var avatar = new SelectionAvatar(user, isOwnUser);
avatars.Add(avatar);
if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME)
{
userAddedSample?.Play();
lastSamplePlayback = Time.Current;
}
updateAvatarLayout();
avatar.FinishTransforms();
return true;
}
public bool RemoveUser(int id)
{
if (avatars.SingleOrDefault(a => a.User.Id == id) is not SelectionAvatar avatar)
return false;
avatar.PopOutAndExpire();
avatars.ChangeChildDepth(avatar, float.MaxValue);
updateAvatarLayout();
return true;
}
private void updateAvatarLayout()
{
const double stagger = 30;
const float spacing = 4;
double delay = 0;
float x = 0;
for (int i = avatars.Count - 1; i >= 0; i--)
{
var avatar = avatars[i];
if (avatar.Expired)
continue;
avatar.Delay(delay).MoveToX(x, 500, Easing.OutElasticQuarter);
x -= avatar.LayoutSize.X + spacing;
delay += stagger;
}
}
public partial class SelectionAvatar : CompositeDrawable
{
public const float AVATAR_SIZE = 30;
public APIUser User { get; }
public bool Expired { get; private set; }
private readonly MatchmakingAvatar avatar;
public SelectionAvatar(APIUser user, bool isOwnUser)
{
User = user;
Size = new Vector2(AVATAR_SIZE);
InternalChild = avatar = new MatchmakingAvatar(user, isOwnUser)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
}
protected override void LoadComplete()
{
base.LoadComplete();
avatar.ScaleTo(0)
.ScaleTo(1, 500, Easing.OutElasticHalf)
.FadeIn(200);
}
public void PopOutAndExpire()
{
avatar.ScaleTo(0, 400, Easing.OutExpo);
this.FadeOut(100).Expire();
Expired = true;
}
}
}
}
}
@@ -0,0 +1,97 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect
{
public partial class SubScreenBeatmapSelect : MatchmakingSubScreen
{
public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Split;
public override Drawable PlayersDisplayArea { get; }
private readonly BeatmapSelectGrid beatmapSelectGrid;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
public SubScreenBeatmapSelect()
{
InternalChildren = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Horizontal = 200 },
Child = beatmapSelectGrid = new BeatmapSelectGrid
{
RelativeSizeAxes = Axes.Both,
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Child = PlayersDisplayArea = new Container().With(d =>
{
d.RelativeSizeAxes = Axes.Both;
})
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
client.ItemAdded += onItemAdded;
foreach (var item in client.Room!.Playlist)
onItemAdded(item);
beatmapSelectGrid.ItemSelected += item => client.MatchmakingToggleSelection(item.ID);
client.MatchmakingItemSelected += onItemSelected;
client.MatchmakingItemDeselected += onItemDeselected;
}
private void onItemAdded(MultiplayerPlaylistItem item) => Scheduler.Add(() =>
{
if (item.Expired)
return;
beatmapSelectGrid.AddItem(item);
});
private void onItemSelected(int userId, long itemId)
{
var user = client.Room!.Users.First(it => it.UserID == userId).User!;
beatmapSelectGrid.SetUserSelection(user, itemId, true);
}
private void onItemDeselected(int userId, long itemId)
{
var user = client.Room!.Users.First(it => it.UserID == userId).User!;
beatmapSelectGrid.SetUserSelection(user, itemId, false);
}
public void RollFinalBeatmap(long[] candidateItems, long finalItem) => beatmapSelectGrid.RollAndDisplayFinalBeatmap(candidateItems, finalItem);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.ItemAdded -= onItemAdded;
client.MatchmakingItemSelected -= onItemSelected;
client.MatchmakingItemDeselected -= onItemDeselected;
}
}
}
}
@@ -0,0 +1,31 @@
// 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.Threading.Tasks;
using osu.Framework.Screens;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Gameplay
{
public partial class ScreenGameplay : MultiplayerPlayer
{
public ScreenGameplay(Room room, PlaylistItem playlistItem, MultiplayerRoomUser[] users)
: base(room, playlistItem, users)
{
}
protected override async Task PrepareScoreForResultsAsync(Score score)
{
await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);
Scheduler.Add(() =>
{
if (this.IsCurrentScreen())
this.Exit();
});
}
}
}
@@ -0,0 +1,72 @@
// 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.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users.Drawables;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match
{
/// <summary>
/// A circular player avatar used in matchmaking displays.
/// Is part of a <see cref="PlayerPanel"/> but can also be used in isolation for a more ambient/decorative user display.
/// </summary>
public partial class MatchmakingAvatar : CompositeDrawable
{
public static readonly Vector2 SIZE = new Vector2(30);
private readonly APIUser user;
private readonly bool isOwnUser;
public MatchmakingAvatar(APIUser user, bool isOwnUser = false)
{
this.user = user;
this.isOwnUser = isOwnUser;
Size = SIZE;
}
[BackgroundDependencyLoader]
private void load(OsuColour colour)
{
if (isOwnUser)
{
AddInternal(new Container
{
RelativeSizeAxes = Axes.Both,
Depth = float.MaxValue,
Padding = new MarginPadding(-2),
Child = new FastCircle
{
RelativeSizeAxes = Axes.Both,
Colour = colour.Yellow,
}
});
}
AddInternal(new CircularContainer
{
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.LightSlateGray,
},
new ClickableAvatar(user, true)
{
RelativeSizeAxes = Axes.Both,
}
}
});
}
}
}
@@ -0,0 +1,70 @@
// 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.Allocation;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Online.Rooms;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Screens.OnlinePlay.Match.Components;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match
{
public partial class MatchmakingChatDisplay : MatchChatDisplay, IKeyBindingHandler<GlobalAction>
{
protected new ChatTextBox TextBox => base.TextBox!;
public MatchmakingChatDisplay(Room room, bool leaveChannelOnDispose = true)
: base(room, leaveChannelOnDispose)
{
}
[BackgroundDependencyLoader]
private void load(RealmKeyBindingStore keyBindingStore)
{
resetPlaceholderText();
TextBox.HoldFocus = false;
TextBox.ReleaseFocusOnCommit = true;
TextBox.Focus = () => TextBox.PlaceholderText = ChatStrings.InputPlaceholder;
TextBox.FocusLost = resetPlaceholderText;
void resetPlaceholderText() => TextBox.PlaceholderText = Localisation.ChatStrings.InGameInputPlaceholder(keyBindingStore.GetBindingsStringFor(GlobalAction.ToggleChatFocus));
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.Back:
if (TextBox.HasFocus)
{
Schedule(() => TextBox.KillFocus());
return true;
}
break;
case GlobalAction.ToggleChatFocus:
if (TextBox.HasFocus)
{
Schedule(() => TextBox.KillFocus());
}
else
{
Schedule(() => TextBox.TakeFocus());
}
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
}
}
@@ -0,0 +1,46 @@
// 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.Graphics;
using osu.Framework.Screens;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match
{
public abstract partial class MatchmakingSubScreen : Screen
{
public abstract PanelDisplayStyle PlayersDisplayStyle { get; }
public abstract Drawable? PlayersDisplayArea { get; }
protected MatchmakingSubScreen()
{
RelativePositionAxes = Axes.X;
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
this.FadeInFromZero(200);
}
public override void OnSuspending(ScreenTransitionEvent e)
{
base.OnSuspending(e);
this.FadeOutFromOne(200);
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
this.FadeInFromZero(200);
}
public override bool OnExiting(ScreenExitEvent e)
{
if (base.OnExiting(e))
return true;
this.FadeOutFromOne(200);
return false;
}
}
}
@@ -0,0 +1,256 @@
// 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.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match
{
/// <summary>
/// A panel used throughout matchmaking to represent a user, including local information like their
/// rank and high level statistics in the matchmaking system.
/// </summary>
public partial class PlayerPanel : UserPanel
{
public static readonly Vector2 SIZE_HORIZONTAL = new Vector2(250, 100);
public static readonly Vector2 SIZE_VERTICAL = new Vector2(150, 200);
private static readonly Vector2 avatar_size = new Vector2(80);
public readonly MultiplayerRoomUser RoomUser;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private OsuSpriteText rankText = null!;
private OsuSpriteText scoreText = null!;
private Drawable avatarPositionTarget = null!;
private Drawable avatarJumpTarget = null!;
private MatchmakingAvatar avatar = null!;
private OsuSpriteText username = null!;
private Container scaleContainer = null!;
private Container mainContent = null!;
public bool Horizontal
{
get => horizontal;
set
{
horizontal = value;
if (IsLoaded)
updateLayout(false);
}
}
private bool horizontal;
public PlayerPanel(MultiplayerRoomUser user)
: base(user.User!)
{
RoomUser = user;
}
[BackgroundDependencyLoader]
private void load()
{
Masking = true;
CornerRadius = 10;
CornerExponent = 10;
Add(scaleContainer = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Child = mainContent = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Children = new[]
{
avatarPositionTarget = new Container
{
Origin = Anchor.Centre,
Size = avatar_size,
Child = avatarJumpTarget = new Container
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both,
Child = avatar = new MatchmakingAvatar(User, isOwnUser: User.Id == api.LocalUser.Value.Id)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Size = Vector2.One
}
}
},
rankText = new OsuSpriteText
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomCentre,
Blending = BlendingParameters.Additive,
Margin = new MarginPadding(4),
Font = OsuFont.Style.Title.With(size: 70),
},
username = new OsuSpriteText
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Text = User.Username,
Font = OsuFont.Style.Heading1,
},
scoreText = new OsuSpriteText
{
Margin = new MarginPadding(10),
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Font = OsuFont.Style.Heading2,
Text = "0 pts"
}
}
}
});
}
protected override Drawable CreateLayout() => Empty();
protected override void LoadComplete()
{
base.LoadComplete();
updateLayout(true);
client.MatchRoomStateChanged += onRoomStateChanged;
client.MatchEvent += onMatchEvent;
onRoomStateChanged(client.Room!.MatchState);
avatar.ScaleTo(0)
.ScaleTo(1, 500, Easing.OutElasticHalf)
.FadeIn(200);
rankText.Hide();
scoreText.Hide();
username.Hide();
using (BeginDelayedSequence(100))
{
username.FadeInFromZero(600);
using (BeginDelayedSequence(100))
{
scoreText.FadeInFromZero(600);
using (BeginDelayedSequence(100))
{
rankText.FadeTo(0.6f, 600);
}
}
}
}
private Vector2 avatarPosition => horizontal ? new Vector2(50) : new Vector2(75, 50);
private void updateLayout(bool instant)
{
double duration = instant ? 0 : 1000;
avatarPositionTarget.MoveTo(avatarPosition, duration, Easing.OutPow10);
this.ResizeTo(horizontal ? SIZE_HORIZONTAL : SIZE_VERTICAL, duration, Easing.OutPow10);
rankText.MoveTo(horizontal ? new Vector2(-40, -10) : new Vector2(-70, 0), duration, Easing.OutPow10);
username.MoveTo(horizontal ? new Vector2(0, -46) : new Vector2(0, -86), duration, Easing.OutPow10);
scoreText.MoveTo(horizontal ? new Vector2(0, -16) : new Vector2(0, -56), duration, Easing.OutPow10);
}
protected override bool OnHover(HoverEvent e)
{
scaleContainer.ScaleTo(1.03f, 750, Easing.OutPow10);
mainContent.ScaleTo(1.03f, 750, Easing.OutPow10);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
scaleContainer.ScaleTo(1f, 750, Easing.OutPow10);
mainContent.ScaleTo(1, 750, Easing.OutPow10);
mainContent.MoveTo(Vector2.Zero, 1250, Easing.OutPow10);
avatarPositionTarget.MoveTo(avatarPosition, 1250, Easing.OutPow10);
base.OnHoverLost(e);
}
protected override bool OnMouseMove(MouseMoveEvent e)
{
var offset = (avatarPositionTarget.ToLocalSpace(e.ScreenSpaceMousePosition) - avatarPositionTarget.DrawSize / 2) * 0.02f;
mainContent.MoveTo(offset * 0.5f, 1000, Easing.OutPow10);
avatarPositionTarget.MoveTo(avatarPosition + offset, 400, Easing.OutPow10);
return base.OnMouseMove(e);
}
private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
{
if (state is not MatchmakingRoomState matchmakingState)
return;
if (!matchmakingState.Users.UserDictionary.TryGetValue(User.Id, out MatchmakingUser? userScore))
return;
rankText.Text = $"#{userScore.Placement}";
scoreText.Text = $"{userScore.Points} pts";
});
private void onMatchEvent(MatchServerEvent e)
{
switch (e)
{
case MatchmakingAvatarActionEvent action:
if (action.UserId != RoomUser.UserID)
break;
switch (action.Action)
{
case MatchmakingAvatarAction.Jump:
avatarJumpTarget.MoveToY(-10, 200, Easing.Out)
.Then().MoveToY(0, 200, Easing.In);
avatarJumpTarget.ScaleTo(new Vector2(1, 1.05f), 200, Easing.Out)
.Then().ScaleTo(new Vector2(1, 0.95f), 200, Easing.In)
.Then().ScaleTo(Vector2.One, 800, Easing.OutElastic);
break;
}
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.MatchRoomStateChanged -= onRoomStateChanged;
client.MatchEvent -= onMatchEvent;
}
}
}
}
@@ -0,0 +1,313 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match
{
/// <summary>
/// A component which maintains the layout of the players in a matchmaking room.
/// Can be controlled to display the panels in a certain location and in multiple styles.
/// </summary>
public partial class PlayerPanelOverlay : CompositeDrawable
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private Container<PlayerPanel> panels = null!;
private PlayerPanelCellContainer gridLayout = null!;
private PlayerPanelCellContainer splitLayoutLeft = null!;
private PlayerPanelCellContainer splitLayoutRight = null!;
private PanelDisplayStyle displayStyle;
private Drawable? displayArea;
private bool isAnimatingToDisplayArea;
[BackgroundDependencyLoader]
private void load()
{
InternalChildren = new Drawable[]
{
gridLayout = new PlayerPanelCellContainer
{
RelativeSizeAxes = Axes.Both,
Spacing = new Vector2(20),
},
splitLayoutLeft = new PlayerPanelCellContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5),
},
splitLayoutRight = new PlayerPanelCellContainer
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5),
},
panels = new Container<PlayerPanel>
{
RelativeSizeAxes = Axes.Both
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
// Set position/size so we don't initially animate.
Position = getFinalPosition();
Size = getFinalSize();
client.MatchRoomStateChanged += onRoomStateChanged;
client.UserJoined += onUserJoined;
client.UserLeft += onUserLeft;
if (client.Room != null)
{
onRoomStateChanged(client.Room.MatchState);
foreach (var user in client.Room.Users)
onUserJoined(user);
}
updateDisplay();
}
public PanelDisplayStyle DisplayStyle
{
set
{
displayStyle = value;
if (IsLoaded)
updateDisplay();
}
}
public Drawable? DisplayArea
{
set
{
displayArea = value;
isAnimatingToDisplayArea = true;
}
}
private void onUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() =>
{
panels.Add(new PlayerPanel(user)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(0.8f)
});
updateDisplay();
});
private void onUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() =>
{
panels.Single(p => p.RoomUser.Equals(user)).Expire();
updateDisplay();
});
private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(updateDisplay);
private void updateDisplay()
{
gridLayout.ReleasePanels();
splitLayoutLeft.ReleasePanels();
splitLayoutRight.ReleasePanels();
switch (displayStyle)
{
case PanelDisplayStyle.Grid:
foreach (var panel in panels)
{
panel.FadeTo(1, 200);
panel.Horizontal = false;
}
gridLayout.AcquirePanels(panels.ToArray());
break;
case PanelDisplayStyle.Split:
foreach (var panel in panels)
{
panel.FadeTo(1, 200);
panel.Horizontal = true;
}
int leftCount = (int)Math.Ceiling(panels.Count / 2f);
splitLayoutLeft.AcquirePanels(panels.Take(leftCount).ToArray());
splitLayoutRight.AcquirePanels(panels.Skip(leftCount).ToArray());
break;
case PanelDisplayStyle.Hidden:
foreach (var panel in panels)
panel.FadeTo(0, 200);
return;
}
}
protected override void Update()
{
base.Update();
var targetPos = getFinalPosition();
var targetSize = getFinalSize();
double duration = isAnimatingToDisplayArea ? 60 : 0;
if (Time.Elapsed > 0)
{
Position = new Vector2(
(float)Interpolation.DampContinuously(Position.X, targetPos.X, duration, Time.Elapsed),
(float)Interpolation.DampContinuously(Position.Y, targetPos.Y, duration, Time.Elapsed)
);
Size = new Vector2(
(float)Interpolation.DampContinuously(Size.X, targetSize.X, duration, Time.Elapsed),
(float)Interpolation.DampContinuously(Size.Y, targetSize.Y, duration, Time.Elapsed)
);
}
// If we don't track the animating state, the animation will also occur when resizing the window.
isAnimatingToDisplayArea &= !Precision.AlmostEquals(Size, targetSize, 0.5f);
}
private Vector2 getFinalPosition()
=> displayArea == null ? Vector2.Zero : Parent!.ToLocalSpace(displayArea.ScreenSpaceDrawQuad.TopLeft);
private Vector2 getFinalSize()
=> displayArea == null ? Parent!.DrawSize : Parent!.ToLocalSpace(displayArea.ScreenSpaceDrawQuad.BottomRight) - Parent!.ToLocalSpace(displayArea.ScreenSpaceDrawQuad.TopLeft);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.MatchRoomStateChanged -= onRoomStateChanged;
client.UserJoined -= onUserJoined;
client.UserLeft -= onUserLeft;
}
}
private partial class PlayerPanelCellContainer : FillFlowContainer<PlayerPanelCell>
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
public void AcquirePanels(PlayerPanel[] panels)
{
while (Count < panels.Length)
{
Add(new PlayerPanelCell
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
while (Count > panels.Length)
Remove(Children[^1], true);
for (int i = 0; i < panels.Length; i++)
{
// We'll invalidate the layout position to represent the new placements and the re-flow will happen in UpdateAfterChildren().
// But the cells expect their positions to be valid as they're updated, which won't be the case until the re-flow happens.
int i2 = i;
ScheduleAfterChildren(() => Children[i2].AcquirePanel(panels[i2]));
if (client.Room?.MatchState is not MatchmakingRoomState matchmakingState)
continue;
if (matchmakingState.Users.UserDictionary.TryGetValue(panels[i].User.Id, out MatchmakingUser? user))
SetLayoutPosition(Children[i], user.Placement);
else
SetLayoutPosition(Children[i], float.MaxValue);
}
}
public void ReleasePanels()
{
// Matches the schedule in AcquirePanels.
ScheduleAfterChildren(() =>
{
foreach (var panel in Children)
panel.ReleasePanel();
});
}
}
private partial class PlayerPanelCell : Drawable
{
private PlayerPanel? panel;
private bool isAnimating;
public void AcquirePanel(PlayerPanel panel)
{
this.panel = panel;
isAnimating = true;
}
public void ReleasePanel()
{
panel = null;
}
protected override void Update()
{
base.Update();
if (panel?.Parent == null)
return;
Size = panel.Horizontal ? PlayerPanel.SIZE_HORIZONTAL : PlayerPanel.SIZE_VERTICAL;
Size *= panel.Scale;
var targetPos = getFinalPosition();
double duration = isAnimating ? 60 : 0;
if (Time.Elapsed > 0)
{
panel.Position = new Vector2(
(float)Interpolation.DampContinuously(panel.Position.X, targetPos.X, duration, Time.Elapsed),
(float)Interpolation.DampContinuously(panel.Position.Y, targetPos.Y, duration, Time.Elapsed)
);
}
// If we don't track the animating state, the animation will also occur when resizing the window.
isAnimating &= !Precision.AlmostEquals(panel.Position, targetPos, 0.5f);
Vector2 getFinalPosition()
=> panel.Parent.ToLocalSpace(ScreenSpaceDrawQuad.Centre) - panel.AnchorPosition;
}
}
}
public enum PanelDisplayStyle
{
Grid,
Split,
Hidden
}
}
@@ -0,0 +1,57 @@
// 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.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Database;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results
{
public partial class PanelRoomAward : CompositeDrawable
{
private readonly Color4 backgroundColour = Color4.SaddleBrown;
private readonly string text;
private readonly int userId;
public PanelRoomAward(string text, int userId)
{
this.text = text;
this.userId = userId;
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load(UserLookupCache userLookupCache)
{
// Should be cached by this point.
APIUser? user = userLookupCache.GetUserAsync(userId).GetResultSafely();
InternalChild = new CircularContainer
{
AutoSizeAxes = Axes.Both,
Masking = true,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = backgroundColour
},
new OsuSpriteText
{
Margin = new MarginPadding(10),
Text = $"{text}: {user?.Username}"
}
}
};
}
}
}
@@ -0,0 +1,49 @@
// 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.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Sprites;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results
{
public partial class PanelUserStatistic : CompositeDrawable
{
private readonly Color4 backgroundColour = Color4.SaddleBrown;
private readonly string text;
public PanelUserStatistic(string text)
{
this.text = text;
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new CircularContainer
{
AutoSizeAxes = Axes.Both,
Masking = true,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = backgroundColour
},
new OsuSpriteText
{
Margin = new MarginPadding(10),
Text = text
}
}
};
}
}
}
@@ -0,0 +1,343 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Rulesets.Scoring;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results
{
/// <summary>
/// Final room results, during <see cref="MatchmakingStage.Ended"/>
/// </summary>
public partial class SubScreenResults : MatchmakingSubScreen
{
private const float grid_spacing = 5;
public override PanelDisplayStyle PlayersDisplayStyle => PanelDisplayStyle.Grid;
public override Drawable PlayersDisplayArea { get; }
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private readonly OsuSpriteText placementText;
private readonly FillFlowContainer<PanelUserStatistic> userStatistics;
private readonly FillFlowContainer<PanelRoomAward> roomStatistics;
public SubScreenResults()
{
InternalChild = new GridContainer
{
RelativeSizeAxes = Axes.Both,
RowDimensions =
[
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, grid_spacing),
new Dimension(),
new Dimension(GridSizeMode.Absolute, grid_spacing),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, 75)
],
Content = new Drawable[]?[]
{
[
new FillFlowContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(grid_spacing),
Children = new[]
{
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = "Placement",
Font = OsuFont.Default.With(size: 12)
},
placementText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Default.With(size: 72),
UseFullGlyphHeight = false
}
}
}
],
null,
[
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions =
[
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, grid_spacing),
new Dimension()
],
Content = new Drawable?[][]
{
[
new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(grid_spacing),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = "Breakdown",
Font = OsuFont.Default.With(size: 12)
},
userStatistics = new FillFlowContainer<PanelUserStatistic>
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(grid_spacing)
}
}
},
null,
PlayersDisplayArea = Empty().With(d =>
{
d.RelativeSizeAxes = Axes.Both;
})
]
}
}
],
null,
[
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(grid_spacing),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = "Statistics",
Font = OsuFont.Default.With(size: 12)
},
roomStatistics = new FillFlowContainer<PanelRoomAward>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(grid_spacing)
}
}
},
],
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
client.MatchRoomStateChanged += onRoomStateChanged;
onRoomStateChanged(client.Room?.MatchState);
}
private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
{
if (state is not MatchmakingRoomState matchmakingState || matchmakingState.Stage != MatchmakingStage.Ended)
return;
populateUserStatistics(matchmakingState);
populateRoomStatistics(matchmakingState);
});
private void populateUserStatistics(MatchmakingRoomState state)
{
userStatistics.Clear();
if (state.Users[client.LocalUser!.UserID].Rounds.Count == 0)
{
placementText.Text = "-";
addStatistic("No rounds played");
return;
}
int overallPlacement = state.Users[client.LocalUser!.UserID].Placement;
int overallPoints = state.Users[client.LocalUser!.UserID].Points;
int bestPlacement = state.Users[client.LocalUser!.UserID].Rounds.Min(r => r.Placement);
var accuracyPlacement = state.Users.Select(u => (user: u, avgAcc: u.Rounds.Select(r => r.Accuracy).DefaultIfEmpty(0).Average()))
.OrderByDescending(t => t.avgAcc)
.Select((t, i) => (info: t, index: i))
.Single(t => t.info.user.UserId == client.LocalUser!.UserID);
placementText.Text = $"#{state.Users[client.LocalUser!.UserID].Placement}";
addStatistic($"#{overallPlacement} overall ({overallPoints}pts)");
addStatistic($"#{bestPlacement} best placement");
addStatistic($"#{accuracyPlacement.index + 1} accuracy ({accuracyPlacement.info.avgAcc.FormatAccuracy()})");
void addStatistic(string text)
{
userStatistics.Add(new PanelUserStatistic(text)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
});
}
}
private void populateRoomStatistics(MatchmakingRoomState state)
{
roomStatistics.Clear();
long maxScore = long.MinValue;
int maxScoreUserId = 0;
double maxAccuracy = double.MinValue;
int maxAccuracyUserId = 0;
int maxCombo = int.MinValue;
int maxComboUserId = 0;
long maxBonusScore = 0;
int maxBonusScoreUserId = 0;
long largestScoreDifference = long.MinValue;
int largestScoreDifferenceUserId = 0;
long smallestScoreDifference = long.MaxValue;
int smallestScoreDifferenceUserId = 0;
for (int round = 1; round <= state.CurrentRound; round++)
{
long roundHighestScore = long.MinValue;
int roundHighestScoreUserId = 0;
long roundLowestScore = long.MaxValue;
foreach (MatchmakingUser user in state.Users)
{
if (!user.Rounds.RoundsDictionary.TryGetValue(round, out MatchmakingRound? mmRound))
continue;
if (mmRound.TotalScore > maxScore)
{
maxScore = mmRound.TotalScore;
maxScoreUserId = user.UserId;
}
if (mmRound.Accuracy > maxAccuracy)
{
maxAccuracy = mmRound.Accuracy;
maxAccuracyUserId = user.UserId;
}
if (mmRound.MaxCombo > maxCombo)
{
maxCombo = mmRound.MaxCombo;
maxComboUserId = user.UserId;
}
if (mmRound.TotalScore > roundHighestScore)
{
roundHighestScore = mmRound.TotalScore;
roundHighestScoreUserId = user.UserId;
}
if (mmRound.TotalScore < roundLowestScore)
roundLowestScore = mmRound.TotalScore;
}
long roundScoreDifference = roundHighestScore - roundLowestScore;
if (roundScoreDifference > 0 && roundScoreDifference > largestScoreDifference)
{
largestScoreDifference = roundScoreDifference;
largestScoreDifferenceUserId = roundHighestScoreUserId;
}
if (roundScoreDifference > 0 && roundScoreDifference < smallestScoreDifference)
{
smallestScoreDifference = roundScoreDifference;
smallestScoreDifferenceUserId = roundHighestScoreUserId;
}
}
foreach (MatchmakingUser user in state.Users)
{
int userBonusScore = 0;
foreach (MatchmakingRound round in user.Rounds)
{
userBonusScore += round.Statistics.TryGetValue(HitResult.LargeBonus, out int bonus) ? bonus * 5 : 0;
userBonusScore += round.Statistics.TryGetValue(HitResult.SmallBonus, out bonus) ? bonus : 0;
}
if (userBonusScore > maxBonusScore)
{
maxBonusScore = userBonusScore;
maxBonusScoreUserId = user.UserId;
}
}
// Highest score - highest score across all rounds.
addStatistic(maxScoreUserId, "Highest score");
// Most accurate - highest accuracy across all rounds.
addStatistic(maxAccuracyUserId, "Most accurate");
// Most combo - highest combo across all rounds.
addStatistic(maxComboUserId, "Most combo");
// Most bonus - most bonus score across all rounds.
if (maxBonusScoreUserId > 0)
addStatistic(maxBonusScoreUserId, "Most bonus");
// Most clutch - smallest victory in any round.
if (smallestScoreDifferenceUserId > 0)
addStatistic(smallestScoreDifferenceUserId, "Most clutch");
// Best finish - largest victory in any round.
if (largestScoreDifferenceUserId > 0)
addStatistic(largestScoreDifferenceUserId, "Best finish");
void addStatistic(int userId, string text)
{
roomStatistics.Add(new PanelRoomAward(text, userId)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
});
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.MatchRoomStateChanged -= onRoomStateChanged;
}
}
}

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