1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-03 15:04:26 +08:00

Add matchmaking

This commit is contained in:
Dan Balasescu
2025-09-02 15:04:22 +09:00
Unverified
parent 058835440d
commit 111b98ef8e
51 changed files with 5637 additions and 18 deletions
@@ -0,0 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapPanel : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add beatmap panel", () =>
{
Child = new BeatmapPanel(CreateAPIBeatmap())
{
Size = new Vector2(300, 70),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
});
}
}
}
@@ -0,0 +1,184 @@
// 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.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osu.Game.Tests.Visual.OnlinePlay;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectionGrid : OnlinePlayTestScene
{
private MultiplayerPlaylistItem[] items = null!;
private BeatmapSelectionGrid 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 BeatmapSelectionGrid
{
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 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(BeatmapSelectionGrid.ARRANGE_DELAY)
.Schedule(() => grid.ArrangeItemsForRollAnimation());
});
AddWaitStep("wait for movement", 5);
AddStep("display roll order", () =>
{
var panels = grid.ChildrenOfType<BeatmapSelectionPanel>().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,68 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectionOverlay : OsuTestScene
{
private BeatmapSelectionOverlay selectionOverlay = null!;
[SetUpSteps]
public void SetupSteps()
{
AddStep("add drawable", () => Child = new Container
{
Width = 100,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(2),
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0.1f,
},
selectionOverlay = new BeatmapSelectionOverlay
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
}
}
});
}
[Test]
public void TestSelectionOverlay()
{
AddStep("add maarvin", () => selectionOverlay.AddUser(new APIUser
{
Id = 6411631,
Username = "Maarvin",
}, isOwnUser: true));
AddStep("add peppy", () => selectionOverlay.AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}, false));
AddStep("add smogipoo", () => selectionOverlay.AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}, false));
AddStep("remove smogipoo", () => selectionOverlay.RemoveUser(1040328));
AddStep("remove peppy", () => selectionOverlay.RemoveUser(2));
AddStep("remove maarvin", () => selectionOverlay.RemoveUser(6411631));
}
}
}
@@ -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.Screens.Pick;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectionPanel : MultiplayerTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
[Test]
public void TestBeatmapPanel()
{
BeatmapSelectionPanel? panel = null;
AddStep("add panel", () => Child = panel = new BeatmapSelectionPanel(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,89 @@
// 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.Screens.OnlinePlay.Matchmaking.Screens.Idle;
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()));
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 IdleScreen())
{
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;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingCloud : OsuTestScene
{
private MatchmakingCloud cloud = null!;
protected override void LoadComplete()
{
base.LoadComplete();
Child = cloud = new MatchmakingCloud
{
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,55 @@
// 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;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingQueueScreen : ScreenTestScene
{
[Cached]
private readonly MatchmakingController controller = new MatchmakingController();
private MatchmakingQueueScreen? queueScreen => Stack.CurrentScreen as MatchmakingQueueScreen;
[SetUpSteps]
public override void SetUpSteps()
{
AddStep("load screen", () => LoadScreen(new MatchmakingIntroScreen()));
}
[Test]
public void TestBasic()
{
AddUntilStep("wait for queue screen", () => queueScreen != null);
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(MatchmakingQueueScreen.MatchmakingScreenState.Idle));
AddStep("change state to queueing", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.Queueing));
AddStep("change state to found match", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.PendingAccept));
AddStep("change state to waiting for room", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.AcceptedWaitingForRoom));
AddStep("change state to in room", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.InRoom));
}
}
}
@@ -0,0 +1,244 @@
// 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.Graphics.Primitives;
using osu.Framework.Screens;
using osu.Framework.Testing;
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;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
using osuTK.Input;
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 MatchmakingScreen screen = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom();
room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
})).ToArray();
JoinRoom(room);
});
WaitForJoined();
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 MatchmakingScreen(new MultiplayerRoom(0)
{
Users = users,
Playlist = beatmaps
}));
});
AddUntilStep("wait for load", () => screen.IsCurrentScreen());
}
[Test]
public void TestGameplayFlow()
{
// Initial "ready" status of the room".
AddWaitStep("wait", 5);
AddStep("round start", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.RoundWarmupTime
}).WaitSafely());
// Next round starts with picks.
AddWaitStep("wait", 5);
AddStep("pick", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.UserBeatmapSelect
}).WaitSafely());
// Make some selections
AddWaitStep("wait", 5);
for (int i = 0; i < 3; i++)
{
int j = i * 2;
AddStep("click a beatmap", () =>
{
Quad panelQuad = this.ChildrenOfType<BeatmapPanel>().ElementAt(j).ScreenSpaceDrawQuad;
InputManager.MoveMouseTo(new Vector2(panelQuad.Centre.X, panelQuad.TopLeft.Y + 5));
InputManager.Click(MouseButton.Left);
});
AddWaitStep("wait", 2);
}
// Lock in the gameplay beatmap
AddStep("selection", () =>
{
MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.ServerBeatmapFinalised,
CandidateItems = beatmaps.Select(b => b.ID).ToArray(),
CandidateItem = beatmaps[0].ID
}).WaitSafely();
});
// Prepare gameplay.
AddWaitStep("wait", 25);
AddStep("prepare gameplay", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.GameplayWarmupTime
}).WaitSafely());
// Start gameplay.
AddWaitStep("wait", 5);
AddStep("gameplay", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.Gameplay
}).WaitSafely());
AddStep("start gameplay", () => MultiplayerClient.StartMatch().WaitSafely());
// AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true);
// Finish gameplay.
AddWaitStep("wait", 5);
AddStep("round end", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.ResultsDisplaying
}).WaitSafely());
AddWaitStep("wait", 10);
AddStep("room end", () =>
{
MatchmakingRoomState state = new MatchmakingRoomState
{
CurrentRound = 1,
Stage = MatchmakingStage.Ended
};
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;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
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,119 @@
// 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.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.Screens;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingScreenStack : MultiplayerTestScene
{
private const int user_count = 8;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom();
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("add carousel", () =>
{
Child = new MatchmakingScreenStack
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
};
});
AddStep("join users", () =>
{
var users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"Player {i}"
}
}).ToArray();
foreach (var user in users)
MultiplayerClient.AddUser(user);
});
}
[Test]
public void TestStatus()
{
AddWaitStep("wait for scroll", 5);
AddStep("pick", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.UserBeatmapSelect
}).WaitSafely());
AddWaitStep("wait for scroll", 5);
AddStep("selection", () =>
{
MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
beatmaps = Random.Shared.GetItems(beatmaps, 8);
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = MatchmakingStage.ServerBeatmapFinalised,
CandidateItems = beatmaps.Select(b => b.ID).ToArray(),
CandidateItem = beatmaps[0].ID
}).WaitSafely();
});
AddWaitStep("wait for scroll", 35);
AddStep("room end", () =>
{
var state = new MatchmakingRoomState
{
CurrentRound = 1,
Stage = MatchmakingStage.Ended
};
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;
state.Users[1].Placement = 2;
state.Users[1].Rounds[1].Placement = 2;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}
@@ -0,0 +1,106 @@
// 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.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
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();
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>();
PickScreen screen = null!;
AddStep("add screen", () => LoadScreen(screen = new PickScreen()));
AddStep("select maps", () =>
{
selectedItems.Clear();
foreach (var user in users)
{
var item = items[Random.Shared.Next(items.Length)];
selectedItems.Add(item.ID);
MultiplayerClient.MatchmakingToggleUserSelection(user.Id, item.ID).FireAndForget();
}
});
AddStep("show final map", () =>
{
long[] candidateItems = selectedItems.ToArray();
long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)];
screen.RollFinalBeatmap(candidateItems, finalItem);
});
}
}
}
@@ -0,0 +1,94 @@
// 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.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle;
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()));
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());
}
}
}
@@ -0,0 +1,98 @@
// 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.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.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()));
WaitForJoined();
AddStep("add results screen", () =>
{
Child = new ScreenStack(new ResultsScreen())
{
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,32 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneRoomStatisticPanel : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add statistic", () => Child = new RoomStatisticPanel("Statistic description", new MultiplayerRoomUser(1)
{
User = new APIUser
{
Id = 1,
Username = "peppy"
}
})
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
}
}
@@ -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.Screens.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()));
WaitForJoined();
setupRequestHandler();
AddStep("load screen", () =>
{
Child = new ScreenStack(new RoundResultsScreen())
{
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,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.Screens.OnlinePlay.Matchmaking;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageBubble : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
AddStep("add bubble", () => Child = new StageBubble(MatchmakingStage.RoundWarmupTime, "Next Round")
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 100
});
}
[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,56 @@
// 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.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageDisplay : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
AddStep("add bubble", () => Child = new StageDisplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
Width = 0.5f,
});
}
[Test]
public void TestStartCountdown()
{
foreach (var status in Enum.GetValues<MatchmakingStage>())
{
AddStep($"{status}", () =>
{
MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Stage = status
}).WaitSafely();
MultiplayerClient.StartCountdown(new MatchmakingStageCountdown
{
Stage = status,
TimeRemaining = TimeSpan.FromSeconds(5)
}).WaitSafely();
});
AddWaitStep("wait a bit", 10);
}
}
}
}
@@ -0,0 +1,43 @@
// 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.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageText : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom()));
WaitForJoined();
AddStep("create display", () => Child = new StageText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
[TestCase(MatchmakingStage.WaitingForClientsJoin)]
[TestCase(MatchmakingStage.RoundWarmupTime)]
[TestCase(MatchmakingStage.UserBeatmapSelect)]
[TestCase(MatchmakingStage.ServerBeatmapFinalised)]
[TestCase(MatchmakingStage.WaitingForClientsBeatmapDownload)]
[TestCase(MatchmakingStage.GameplayWarmupTime)]
[TestCase(MatchmakingStage.Gameplay)]
[TestCase(MatchmakingStage.ResultsDisplaying)]
[TestCase(MatchmakingStage.Ended)]
public void TestStatus(MatchmakingStage status)
{
AddStep("set status", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState { Stage = status }).WaitSafely());
}
}
}
@@ -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
{
@@ -177,5 +177,28 @@ namespace osu.Game.Tests.Visual.UserInterface
}));
AddAssert("no notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero);
}
[Test]
public void TestMatchmaking()
{
AddStep("add content", () =>
{
Children = new Drawable[]
{
new DependencyProvidingContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Child = new MatchmakingButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
ButtonSystemState = ButtonSystemState.TopLevel,
},
},
};
});
}
}
}
@@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Rooms;
namespace osu.Game.Online.Multiplayer
@@ -13,7 +14,7 @@ namespace osu.Game.Online.Multiplayer
/// <summary>
/// An interface defining a multiplayer client instance.
/// </summary>
public interface IMultiplayerClient : IStatefulUserHubClient
public interface IMultiplayerClient : IStatefulUserHubClient, IMatchmakingClient
{
/// <summary>
/// Signals that the room has changed state.
@@ -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
{
public Action<Notification>? PostNotification { protected get; set; }
@@ -112,6 +113,22 @@ namespace osu.Game.Online.Multiplayer
/// </summary>
public event Action? Disconnecting;
public event Action<MultiplayerCountdown>? CountdownStarted;
public event Action<MultiplayerCountdown>? CountdownStopped;
public event Action<MultiplayerRoomUser, MultiplayerUserState>? UserStateChanged;
public event Action? MatchmakingQueueJoined;
public event Action? MatchmakingQueueLeft;
public event Action? MatchmakingRoomInvited;
public event Action<long>? 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 +196,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 +275,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 +664,7 @@ namespace osu.Game.Online.Multiplayer
user.State = state;
updateUserPlayingState(userId, state);
UserStateChanged?.Invoke(user, state);
RoomUpdated?.Invoke();
});
@@ -672,6 +697,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(Room != null);
Room.MatchState = state;
MatchRoomStateChanged?.Invoke(state);
RoomUpdated?.Invoke();
});
@@ -688,6 +714,7 @@ namespace osu.Game.Online.Multiplayer
{
case CountdownStartedEvent countdownStartedEvent:
Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown);
CountdownStarted?.Invoke(countdownStartedEvent.Countdown);
switch (countdownStartedEvent.Countdown)
{
@@ -700,8 +727,13 @@ 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;
}
@@ -1001,6 +1033,80 @@ 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)
{
Scheduler.Add(() => MatchmakingRoomReady?.Invoke(roomId));
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 MatchmakingJoinLobby();
public abstract Task MatchmakingLeaveLobby();
public abstract Task MatchmakingJoinQueue(MatchmakingSettings settings);
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(IMultiplayerClient.MatchmakingQueueJoined), ((IMultiplayerClient)this).MatchmakingQueueJoined);
connection.On(nameof(IMultiplayerClient.MatchmakingQueueLeft), ((IMultiplayerClient)this).MatchmakingQueueLeft);
connection.On(nameof(IMultiplayerClient.MatchmakingRoomInvited), ((IMultiplayerClient)this).MatchmakingRoomInvited);
connection.On<long>(nameof(IMultiplayerClient.MatchmakingRoomReady), ((IMultiplayerClient)this).MatchmakingRoomReady);
connection.On<MatchmakingLobbyStatus>(nameof(IMultiplayerClient.MatchmakingLobbyStatusChanged), ((IMultiplayerClient)this).MatchmakingLobbyStatusChanged);
connection.On<MatchmakingQueueStatus>(nameof(IMultiplayerClient.MatchmakingQueueStatusChanged), ((IMultiplayerClient)this).MatchmakingQueueStatusChanged);
connection.On<int, long>(nameof(IMultiplayerClient.MatchmakingItemSelected), ((IMultiplayerClient)this).MatchmakingItemSelected);
connection.On<int, long>(nameof(IMultiplayerClient.MatchmakingItemDeselected), ((IMultiplayerClient)this).MatchmakingItemDeselected);
};
IsConnected.BindTo(connector.IsConnected);
@@ -310,6 +320,78 @@ namespace osu.Game.Online.Multiplayer
return connector.Disconnect();
}
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(MatchmakingSettings settings)
{
if (!IsConnected.Value)
return Task.CompletedTask;
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinQueue), settings);
}
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);
+2
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;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play;
@@ -1270,6 +1271,7 @@ namespace osu.Game
loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add);
loadComponentSingleFile<BeatmapStore>(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true);
loadComponentSingleFile(new MatchmakingController(), Add, true);
Add(externalLinkOpener = new ExternalLinkOpener());
Add(new MusicKeyBindingHandler());
+19 -3
View File
@@ -46,6 +46,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;
@@ -138,23 +139,27 @@ 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 MatchmakingButton(@"button-default-select", new Color4(94, 63, 186, 255), onMatchmaking, Key.N));
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)
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));
@@ -191,6 +196,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)
+4
View File
@@ -37,6 +37,7 @@ using osu.Game.Rulesets;
using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Edit;
using osu.Game.Screens.OnlinePlay.DailyChallenge;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.SelectV2;
@@ -159,6 +160,7 @@ namespace osu.Game.Screens.Menu
},
OnSolo = loadSongSelect,
OnMultiplayer = () => this.Push(new Multiplayer()),
OnMatchmaking = joinOrLeaveMatchmakingQueue,
OnPlaylists = () => this.Push(new Playlists()),
OnDailyChallenge = room =>
{
@@ -481,6 +483,8 @@ namespace osu.Game.Screens.Menu
private void loadSongSelect() => this.Push(new SoloSongSelect());
private void joinOrLeaveMatchmakingQueue() => this.Push(new MatchmakingIntroScreen());
private partial class MobileDisclaimerDialog : PopupDialog
{
public MobileDisclaimerDialog(Action confirmed)
@@ -0,0 +1,19 @@
// 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.Graphics.Sprites;
using osu.Framework.Input.Events;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.Menu
{
public partial class MatchmakingButton : MainMenuButton
{
public MatchmakingButton(string sampleName, Color4 colour, Action<MainMenuButton, UIEvent>? clickAction = null, params Key[] triggerKeys)
: base("matchmaking", sampleName, FontAwesome.Solid.Newspaper, colour, clickAction, triggerKeys)
{
}
}
}
@@ -0,0 +1,68 @@
// 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
{
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,117 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.Ranking;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking
{
public partial class MatchmakingCloud : CompositeDrawable
{
private APIUser[] users = [];
private Container usersContainer = null!;
public APIUser[] Users
{
get => users;
set
{
users = value;
foreach (var u in usersContainer)
u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire();
LoadComponentsAsync(users.Select(u => new MovingAvatar(u)), avatars =>
{
if (usersContainer.Count == 0)
{
usersContainer.ScaleTo(0)
.ScaleTo(1, 5000, Easing.OutPow10);
}
usersContainer.AddRange(avatars);
});
}
}
protected override void LoadComplete()
{
base.LoadComplete();
InternalChildren = new Drawable[]
{
usersContainer = new AspectContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
},
};
}
public partial class MovingAvatar : MatchmakingAvatar
{
private float angle;
private float angularSpeed;
private float targetSpeed;
private float targetScale;
private float targetAlpha;
public MovingAvatar(APIUser apiUser)
: base(apiUser)
{
RelativePositionAxes = Axes.Both;
Scale = new Vector2(2);
Origin = Anchor.Centre;
}
protected override void LoadComplete()
{
base.LoadComplete();
updateParams();
angle = RNG.NextSingle(0f, MathF.Tau);
angularSpeed = targetSpeed;
Scale = new Vector2(targetScale);
Hide();
this.Delay(RNG.Next(0, 1000)).FadeTo(targetAlpha, 2000, Easing.OutQuint);
}
private void updateParams()
{
targetSpeed = RNG.NextSingle(0.05f, 0.5f);
targetScale = RNG.NextSingle(0.2f, 3f);
targetAlpha = RNG.NextSingle(0.5f, 1f);
Scheduler.AddDelayed(updateParams, RNG.Next(500, 5000));
}
protected override void Update()
{
base.Update();
float elapsed = (float)Math.Min(20, Time.Elapsed) / 1000;
Scale = new Vector2((float)Interpolation.Lerp(Scale.X, targetScale, elapsed / 100));
Alpha = (float)Interpolation.Lerp(Alpha, targetAlpha, elapsed / 100);
angularSpeed = (float)Interpolation.Lerp(angularSpeed, targetSpeed, elapsed / 100);
angle += angularSpeed * elapsed * 0.5f;
Position = new Vector2(0.5f) +
new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * angularSpeed;
}
}
}
}
@@ -0,0 +1,170 @@
// 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.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens;
namespace osu.Game.Screens.OnlinePlay.Matchmaking
{
public partial class MatchmakingController : Component
{
public readonly Bindable<MatchmakingQueueScreen.MatchmakingScreenState> CurrentState = new Bindable<MatchmakingQueueScreen.MatchmakingScreenState>();
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private INotificationOverlay? notifications { get; set; }
[Resolved]
private IPerformFromScreenRunner? performer { get; set; }
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
private ProgressNotification? backgroundNotification;
private Notification? readyNotification;
private bool isBackgrounded;
protected override void LoadComplete()
{
base.LoadComplete();
client.RoomUpdated += onRoomUpdated;
client.MatchmakingQueueJoined += onMatchmakingQueueJoined;
client.MatchmakingQueueLeft += onMatchmakingQueueLeft;
client.MatchmakingRoomInvited += onMatchmakingRoomInvited;
client.MatchmakingRoomReady += onMatchmakingRoomReady;
ruleset.BindValueChanged(_ => client.MatchmakingLeaveQueue().FireAndForget());
}
public void SearchInBackground()
{
if (isBackgrounded)
return;
isBackgrounded = true;
postNotification();
}
public void SearchInForeground()
{
if (!isBackgrounded)
return;
isBackgrounded = false;
closeNotifications();
}
private void onRoomUpdated() => Scheduler.Add(() =>
{
if (client.Room == null)
CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.Idle;
});
private void onMatchmakingQueueJoined() => Scheduler.Add(() =>
{
CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.Queueing;
if (isBackgrounded)
{
closeNotifications();
postNotification();
}
});
private void onMatchmakingQueueLeft() => Scheduler.Add(() =>
{
if (CurrentState.Value != MatchmakingQueueScreen.MatchmakingScreenState.InRoom)
CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.Idle;
closeNotifications();
});
private void onMatchmakingRoomInvited() => Scheduler.Add(() =>
{
CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.PendingAccept;
if (backgroundNotification != null)
{
backgroundNotification.State = ProgressNotificationState.Completed;
backgroundNotification = null;
}
});
private void onMatchmakingRoomReady(long roomId) => Scheduler.Add(() =>
{
client.JoinRoom(new Room { RoomID = roomId })
.FireAndForget(() => Scheduler.Add(() =>
{
CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.InRoom;
}));
});
private void postNotification()
{
if (backgroundNotification != null)
return;
notifications?.Post(backgroundNotification = new ProgressNotification
{
Text = "Searching for opponents...",
CompletionTarget = n => notifications.Post(readyNotification = n),
CompletionText = "Your match is ready! Click to join.",
CompletionClickAction = () =>
{
client.MatchmakingAcceptInvitation().FireAndForget();
performer?.PerformFromScreen(s => s.Push(new MatchmakingIntroScreen()));
closeNotifications();
return true;
},
CancelRequested = () =>
{
client.MatchmakingLeaveQueue().FireAndForget();
closeNotifications();
return true;
}
});
}
private void closeNotifications()
{
if (backgroundNotification != null)
{
backgroundNotification.State = ProgressNotificationState.Cancelled;
backgroundNotification.Close(false);
}
readyNotification?.Close(false);
backgroundNotification = null;
readyNotification = null;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.RoomUpdated -= onRoomUpdated;
client.MatchmakingQueueJoined -= onMatchmakingQueueJoined;
client.MatchmakingQueueLeft -= onMatchmakingQueueLeft;
client.MatchmakingRoomInvited -= onMatchmakingRoomInvited;
client.MatchmakingRoomReady -= onMatchmakingRoomReady;
}
}
}
}
@@ -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
{
public partial class MatchmakingPlayer : MultiplayerPlayer
{
public MatchmakingPlayer(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,342 @@
// 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 System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Online;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
using osu.Game.Screens.Footer;
using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking
{
public partial class MatchmakingScreen : OsuScreen
{
/// <summary>
/// Padding between rows of the content.
/// </summary>
private const float row_padding = 10;
public override bool? ApplyModTrackAdjustments => true;
public override bool DisallowExternalBeatmapRulesetChanges => true;
public override bool ShowFooter => true;
[Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))]
private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker();
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
[Resolved]
private BeatmapModelDownloader beatmapDownloader { get; set; } = null!;
[Resolved]
private IDialogOverlay dialogOverlay { get; set; } = null!;
private readonly MultiplayerRoom room;
private CancellationTokenSource? downloadCheckCancellation;
private int? lastDownloadCheckedBeatmapId;
public MatchmakingScreen(MultiplayerRoom room)
{
this.room = room;
Activity.Value = new UserActivity.InLobby(room);
Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING };
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
beatmapAvailabilityTracker,
new MultiplayerRoomSounds(),
new GridContainer
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Horizontal = WaveOverlayContainer.WIDTH_PADDING,
Bottom = ScreenFooter.HEIGHT + 20
},
RowDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.Absolute, row_padding),
new Dimension(GridSizeMode.AutoSize),
},
Content = new Drawable[]?[]
{
[
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 10,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary.
},
new MatchmakingScreenStack(),
}
}
],
null,
[
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.X,
Height = 100,
Padding = new MarginPadding
{
Horizontal = 200,
},
Child = new MatchChatDisplay(new Room(room))
{
RelativeSizeAxes = Axes.Both,
}
},
new RoundedButton
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Text = "Don't click me",
Size = new Vector2(100, 30),
Action = () => client.MatchmakingSkipToNextStage()
}
}
}
]
}
}
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
client.RoomUpdated += onRoomUpdated;
client.UserStateChanged += onUserStateChanged;
client.SettingsChanged += onSettingsChanged;
client.LoadRequested += onLoadRequested;
beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true);
}
private void onRoomUpdated()
{
if (this.IsCurrentScreen() && client.Room == null)
{
Logger.Log($"{this} exiting due to loss of room or connection");
this.Exit();
}
}
private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state)
{
if (user.Equals(client.LocalUser) && state == MultiplayerUserState.Idle)
this.MakeCurrent();
}
private void onSettingsChanged(MultiplayerRoomSettings _) => Scheduler.Add(() =>
{
checkForAutomaticDownload();
updateGameplayState();
});
private void onBeatmapAvailabilityChanged(ValueChangedEvent<BeatmapAvailability> e) => Scheduler.Add(() =>
{
if (client.Room == null || client.LocalUser == null)
return;
client.ChangeBeatmapAvailability(e.NewValue).FireAndForget();
switch (e.NewValue.State)
{
case DownloadState.NotDownloaded:
case DownloadState.LocallyAvailable:
updateGameplayState();
break;
}
});
private void updateGameplayState()
{
if (client.Room?.MatchState is not MatchmakingRoomState matchmakingState)
return;
if (matchmakingState.Stage != MatchmakingStage.WaitingForClientsBeatmapDownload)
return;
MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem;
RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!;
Ruleset rulesetInstance = ruleset.CreateInstance();
// Update global gameplay state to correspond to the new selection.
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID);
Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap);
Ruleset.Value = ruleset;
Mods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
if (Beatmap.Value is DummyWorkingBeatmap)
client.ChangeState(MultiplayerUserState.Idle).FireAndForget();
else
client.ChangeState(MultiplayerUserState.Ready).FireAndForget();
}
private void onLoadRequested() => Scheduler.Add(() =>
{
updateGameplayState();
this.Push(new MultiplayerPlayerLoader(() => new MatchmakingPlayer(new Room(room), new PlaylistItem(client.Room!.CurrentPlaylistItem), room.Users.ToArray())));
});
private void checkForAutomaticDownload()
{
if (client.Room == null)
return;
MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem;
// This method is called every time anything changes in the room.
// This could result in download requests firing far too often, when we only expect them to fire once per beatmap.
//
// Without this check, we would see especially egregious behaviour when a user has hit the download rate limit.
if (lastDownloadCheckedBeatmapId == item.BeatmapID)
return;
lastDownloadCheckedBeatmapId = item.BeatmapID;
downloadCheckCancellation?.Cancel();
// In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes.
// ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised.
beatmapLookupCache
.GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token)
.ContinueWith(resolved => Schedule(() =>
{
var beatmapSet = resolved.GetResultSafely()?.BeatmapSet;
if (beatmapSet == null)
return;
if (beatmapManager.IsAvailableLocally(new BeatmapSetInfo { OnlineID = beatmapSet.OnlineID }))
return;
beatmapDownloader.Download(beatmapSet);
}));
}
private bool exitConfirmed;
public override bool OnExiting(ScreenExitEvent e)
{
if (base.OnExiting(e))
return true;
if (exitConfirmed)
{
client.LeaveRoom().FireAndForget();
return false;
}
if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog)
confirmDialog.PerformOkAction();
else
{
dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () =>
{
exitConfirmed = true;
if (this.IsCurrentScreen())
this.Exit();
}));
}
return true;
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
if (e.Last is not MultiplayerPlayerLoader playerLoader)
return;
if (!playerLoader.GameplayPassed)
{
client.AbortGameplay().FireAndForget();
return;
}
client.ChangeState(MultiplayerUserState.Idle);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.RoomUpdated -= onRoomUpdated;
client.UserStateChanged -= onUserStateChanged;
client.SettingsChanged -= onSettingsChanged;
client.LoadRequested -= onLoadRequested;
}
}
}
}
@@ -0,0 +1,27 @@
// 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.Screens;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle
{
public partial class IdleScreen : MatchmakingSubScreen
{
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new PlayerPanelList
{
RelativeSizeAxes = Axes.Both
};
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
this.MoveToX(0);
}
}
}
@@ -0,0 +1,197 @@
// 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.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle
{
public partial class PlayerPanel : UserPanel
{
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 MatchmakingAvatar avatar = null!;
private OsuSpriteText username = 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;
Add(mainContent = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
avatar = new MatchmakingAvatar(User, isOwnUser: User.Id == api.LocalUser.Value.Id)
{
Anchor = Anchor.TopLeft,
Origin = Anchor.Centre,
Size = new Vector2(80),
},
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;
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;
avatar.MoveTo(avatarPosition, duration, Easing.OutPow10);
this.ResizeTo(horizontal ? new Vector2(250, 100) : new Vector2(150, 200), 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)
{
this.ScaleTo(1.02f, 1000, Easing.OutQuint);
mainContent.ScaleTo(1.03f, 1000, Easing.OutQuint);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
this.ScaleTo(1f, 500, Easing.OutQuint);
mainContent.ScaleTo(1, 500, Easing.OutQuint);
mainContent.MoveTo(Vector2.Zero, 500, Easing.OutElasticHalf);
avatar.MoveTo(avatarPosition, 1500, Easing.OutElastic);
base.OnHoverLost(e);
}
protected override bool OnMouseMove(MouseMoveEvent e)
{
var offset = (avatar.ToLocalSpace(e.ScreenSpaceMousePosition) - avatar.DrawSize / 2) * 0.02f;
mainContent.MoveTo(offset * 0.5f, 1000, Easing.OutQuint);
avatar.MoveTo(avatarPosition + offset, 400, Easing.OutQuint);
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";
});
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.MatchRoomStateChanged -= onRoomStateChanged;
}
}
}
@@ -0,0 +1,80 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle
{
public partial class PlayerPanelList : CompositeDrawable
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
public bool Horizontal { get; init; }
private FillFlowContainer<PlayerPanel> panels = null!;
[BackgroundDependencyLoader]
private void load()
{
InternalChild = panels = new FillFlowContainer<PlayerPanel>
{
RelativeSizeAxes = Axes.Both,
Spacing = new Vector2(20, 5),
LayoutEasing = Easing.InOutQuint,
LayoutDuration = 500
};
}
protected override void LoadComplete()
{
base.LoadComplete();
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);
}
}
private void onUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() =>
{
panels.Add(new PlayerPanel(user)
{
Horizontal = Horizontal,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
});
private void onUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() =>
{
panels.Single(p => p.RoomUser.Equals(user)).Expire();
});
private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
{
if (state is not MatchmakingRoomState matchmakingState)
return;
foreach (var panel in panels)
{
if (matchmakingState.Users.UserDictionary.TryGetValue(panel.User.Id, out MatchmakingUser? user))
panels.SetLayoutPosition(panel, user.Placement);
else
panels.SetLayoutPosition(panel, float.MaxValue);
}
});
}
}
@@ -0,0 +1,263 @@
// 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;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens
{
public partial class MatchmakingIntroScreen : 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!;
[Resolved]
private MatchmakingController controller { get; set; } = null!;
public override bool AllowUserExit => !ValidForResume;
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);
[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 = "Matchmaking",
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();
controller.SearchInForeground();
}
public override void OnSuspending(ScreenTransitionEvent e)
{
ValidForResume = false;
this.FadeOut(800, Easing.OutQuint);
base.OnSuspending(e);
}
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(2750))
{
Schedule(() =>
{
duckOperation?.Dispose();
});
}
}
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 MatchmakingQueueScreen());
});
}
}
}
}
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,393 @@
// 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.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Screens;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens
{
public partial class MatchmakingQueueScreen : OsuScreen
{
public override bool ShowFooter => true;
private Container mainContent = null!;
private MatchmakingScreenState state;
private MatchmakingCloud cloud = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private IDialogOverlay dialogOverlay { get; set; } = null!;
[Resolved]
private MatchmakingController controller { get; set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
private readonly IBindable<MatchmakingScreenState> currentState = new Bindable<MatchmakingScreenState>();
private CancellationTokenSource userLookupCancellation = new CancellationTokenSource();
protected override void LoadComplete()
{
base.LoadComplete();
InternalChildren = new Drawable[]
{
cloud = new MatchmakingCloud
{
Y = -100,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.6f)
},
new MatchmakingAvatar(api.LocalUser.Value, true)
{
Y = -100,
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new Container
{
RelativePositionAxes = Axes.Y,
Y = 0.25f,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
CornerRadius = 10f,
Masking = true,
Children = new Drawable[]
{
new Box
{
Colour = colourProvider.Background3,
RelativeSizeAxes = Axes.Both,
},
mainContent = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Alpha = 0,
AutoSizeAxes = Axes.Both,
AutoSizeDuration = 300,
AutoSizeEasing = Easing.OutQuint,
Padding = new MarginPadding(20),
},
}
},
};
currentState.BindTo(controller.CurrentState);
currentState.BindValueChanged(s => SetState(s.NewValue));
client.MatchmakingLobbyStatusChanged += onMatchmakingLobbyStatusChanged;
}
private void onMatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) => Scheduler.Add(() =>
{
userLookupCancellation.Cancel();
var cancellation = userLookupCancellation = new CancellationTokenSource();
userLookupCache.GetUsersAsync(status.UsersInQueue, cancellation.Token)
.ContinueWith(result => Schedule(() =>
{
APIUser?[] users = result.GetResultSafely();
if (!cancellation.IsCancellationRequested)
Users = users.OfType<APIUser>().ToArray();
}), cancellation.Token);
});
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
client.MatchmakingJoinLobby().FireAndForget();
using (BeginDelayedSequence(800))
Schedule(() => SetState(currentState.Value));
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
client.MatchmakingJoinLobby().FireAndForget();
}
public override void OnSuspending(ScreenTransitionEvent e)
{
base.OnSuspending(e);
client.MatchmakingLeaveLobby().FireAndForget();
}
private bool exitConfirmed;
private bool isBackgrounded;
public override bool OnExiting(ScreenExitEvent e)
{
if (base.OnExiting(e))
return true;
client.MatchmakingLeaveLobby().FireAndForget();
if (isBackgrounded)
return false;
if (exitConfirmed)
{
client.MatchmakingLeaveQueue().FireAndForget();
return false;
}
if (currentState.Value == MatchmakingScreenState.Idle)
return false;
if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog)
confirmDialog.PerformOkAction();
else
{
dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave the matchmaking queue?", () =>
{
exitConfirmed = true;
if (this.IsCurrentScreen())
this.Exit();
}));
}
return true;
}
public APIUser[] Users
{
set => cloud.Users = value;
}
public void SetState(MatchmakingScreenState newState)
{
state = newState;
mainContent.FadeInFromZero(500, Easing.OutQuint);
mainContent.Clear();
switch (newState)
{
case MatchmakingScreenState.Idle:
mainContent.Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(20),
Children = new Drawable[]
{
new ShearedButton(200)
{
DarkerColour = colours.Blue2,
LighterColour = colours.Blue1,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Action = () => client.MatchmakingJoinQueue(new MatchmakingSettings { RulesetId = ruleset.Value.OnlineID }).FireAndForget(),
Text = "Begin queueing",
}
}
};
break;
case MatchmakingScreenState.Queueing:
ShearedButton sendToBackgroundButton;
mainContent.Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(20),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Waiting for a game...",
Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate),
},
new LoadingSpinner
{
State = { Value = Visibility.Visible },
},
sendToBackgroundButton = new ShearedButton(200)
{
DarkerColour = colours.Orange3,
LighterColour = colours.Orange4,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Queue in background",
Action = () =>
{
controller.SearchInBackground();
isBackgrounded = true;
this.Exit();
},
Enabled = { Value = false },
TooltipText = "Wait 5 seconds for this option to become available."
}
}
};
Scheduler.AddDelayed(() =>
{
if (state != newState)
return;
sendToBackgroundButton.Enabled.Value = true;
sendToBackgroundButton.TooltipText = "You will receive a notification when your game is ready. Make sure to watch out for it!";
}, 5000);
break;
case MatchmakingScreenState.PendingAccept:
mainContent.Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(20),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Found a match!",
Font = OsuFont.GetFont(size: 32, weight: FontWeight.Regular, typeface: Typeface.TorusAlternate),
},
new ShearedButton(200)
{
DarkerColour = colours.YellowDark,
LighterColour = colours.YellowLight,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Action = () =>
{
client.MatchmakingAcceptInvitation().FireAndForget();
SetState(MatchmakingScreenState.AcceptedWaitingForRoom);
},
Text = "Join match!",
}
}
};
break;
case MatchmakingScreenState.AcceptedWaitingForRoom:
mainContent.Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(20),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Waiting for all players...",
Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate),
},
new LoadingSpinner
{
State = { Value = Visibility.Visible },
},
}
};
break;
case MatchmakingScreenState.InRoom:
// room received, show users and transition to next screen.
mainContent.Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(20),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = "Good luck!",
Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate),
},
}
};
using (BeginDelayedSequence(2000))
Schedule(() => this.Push(new MatchmakingScreen(client.Room!)));
break;
default:
throw new ArgumentOutOfRangeException(nameof(newState), newState, null);
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.MatchmakingLobbyStatusChanged -= onMatchmakingLobbyStatusChanged;
}
public enum MatchmakingScreenState
{
Idle,
Queueing,
PendingAccept,
AcceptedWaitingForRoom,
InRoom
}
}
}
@@ -0,0 +1,121 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results;
using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens
{
public partial class MatchmakingScreenStack : CompositeDrawable
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private ScreenStack screenStack = null!;
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Both;
Padding = new MarginPadding(10);
InternalChild = new GridContainer
{
RelativeSizeAxes = Axes.Both,
RowDimensions = new[] { new Dimension(), new Dimension(GridSizeMode.AutoSize) },
Content = new Drawable[][]
{
[
new GridContainer
{
RelativeSizeAxes = Axes.Both,
ColumnDimensions = new[] { new Dimension(), new Dimension(GridSizeMode.Absolute, 20), new Dimension(GridSizeMode.AutoSize) },
Padding = new MarginPadding { Bottom = 20 },
Content = new Drawable?[][]
{
[
screenStack = new ScreenStack(),
null,
new PlayerPanelList
{
Horizontal = true,
RelativeSizeAxes = Axes.Y,
Width = 250,
Scale = new Vector2(0.8f),
}
]
}
}
],
[
new StageDisplay
{
RelativeSizeAxes = Axes.X
}
]
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
screenStack.Push(new IdleScreen());
client.MatchRoomStateChanged += onMatchRoomStateChanged;
onMatchRoomStateChanged(client.Room!.MatchState);
}
private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
{
if (state is not MatchmakingRoomState matchmakingState)
return;
switch (matchmakingState.Stage)
{
case MatchmakingStage.WaitingForClientsJoin:
case MatchmakingStage.RoundWarmupTime:
while (screenStack.CurrentScreen is not IdleScreen)
screenStack.Exit();
break;
case MatchmakingStage.UserBeatmapSelect:
screenStack.Push(new PickScreen());
break;
case MatchmakingStage.ServerBeatmapFinalised:
Debug.Assert(screenStack.CurrentScreen is PickScreen);
((PickScreen)screenStack.CurrentScreen).RollFinalBeatmap(matchmakingState.CandidateItems, matchmakingState.CandidateItem);
break;
case MatchmakingStage.ResultsDisplaying:
screenStack.Push(new RoundResultsScreen());
break;
case MatchmakingStage.Ended:
screenStack.Push(new ResultsScreen());
break;
}
});
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.MatchRoomStateChanged -= onMatchRoomStateChanged;
}
}
}
@@ -0,0 +1,43 @@
// 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.Screens
{
public partial class MatchmakingSubScreen : Screen
{
public MatchmakingSubScreen()
{
RelativePositionAxes = Axes.X;
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
this.MoveToX(1).MoveToX(0, 200);
}
public override void OnSuspending(ScreenTransitionEvent e)
{
base.OnSuspending(e);
this.MoveToX(-1, 200);
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
this.MoveToX(0, 200);
}
public override bool OnExiting(ScreenExitEvent e)
{
if (base.OnExiting(e))
return true;
this.MoveToX(1, 200);
return false;
}
}
}
@@ -0,0 +1,192 @@
// 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.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick
{
public partial class BeatmapPanel : CompositeDrawable
{
public static readonly Vector2 SIZE = new Vector2(300, 70);
public readonly Container OverlayLayer = new Container { RelativeSizeAxes = Axes.Both };
public APIBeatmap? Beatmap
{
get => beatmap;
set
{
if (beatmap?.OnlineID == value?.OnlineID)
return;
beatmap = value;
if (IsLoaded)
updateContent();
}
}
private APIBeatmap? beatmap;
private Container content = null!;
private UpdateableOnlineBeatmapSetCover cover = null!;
public BeatmapPanel(APIBeatmap? beatmap = null)
{
this.beatmap = beatmap;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
Masking = true;
CornerRadius = 6;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background3
},
cover = new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.Card, timeBeforeLoad: 0)
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.1f), Color4.White.Opacity(0.3f))
},
content = new Container
{
RelativeSizeAxes = Axes.Both,
},
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 6,
BorderThickness = 2,
BorderColour = ColourInfo.GradientVertical(colourProvider.Background1, colourProvider.Background1.Opacity(0)),
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true,
},
},
OverlayLayer,
};
}
protected override void LoadComplete()
{
base.LoadComplete();
updateContent();
FinishTransforms(true);
}
private void updateContent()
{
foreach (var child in content.Children)
child.FadeOut(300).Expire();
cover.OnlineInfo = beatmap?.BeatmapSet;
if (beatmap != null)
{
var panelContent = new BeatmapPanelContent(beatmap)
{
RelativeSizeAxes = Axes.Both,
};
content.Add(panelContent);
panelContent.FadeInFromZero(300);
}
}
private partial class BeatmapPanelContent : CompositeDrawable
{
private readonly APIBeatmap beatmap;
public BeatmapPanelContent(APIBeatmap beatmap)
{
this.beatmap = beatmap;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new FillFlowContainer
{
Direction = FillDirection.Vertical,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Padding = new MarginPadding { Horizontal = 12 },
Children = new Drawable[]
{
new TruncatingSpriteText
{
Text = new RomanisableString(beatmap.Metadata.TitleUnicode, beatmap.Metadata.TitleUnicode),
Font = OsuFont.Default.With(size: 19, weight: FontWeight.SemiBold),
Shadow = false,
RelativeSizeAxes = Axes.X,
},
new TextFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold);
}).With(d =>
{
d.RelativeSizeAxes = Axes.X;
d.AutoSizeAxes = Axes.Y;
d.AddText("by ");
d.AddText(new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist));
}),
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Top = 6 },
Spacing = new Vector2(4),
Children = new Drawable[]
{
new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new TruncatingSpriteText
{
Text = beatmap.DifficultyName,
Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold),
Shadow = false,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
}
},
},
};
}
}
}
}
@@ -0,0 +1,340 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.Toolkit.HighPerformance;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Transforms;
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.Screens.Pick
{
public partial class BeatmapSelectionGrid : 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 = 20;
public event Action<MultiplayerPlaylistItem>? ItemSelected;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private readonly Dictionary<long, BeatmapSelectionPanel> panelLookup = new Dictionary<long, BeatmapSelectionPanel>();
private readonly PanelGridContainer panelGridContainer;
private readonly Container<BeatmapSelectionPanel> rollContainer;
private readonly OsuScrollContainer scroll;
private bool allowSelection = true;
public BeatmapSelectionGrid()
{
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<BeatmapSelectionPanel>
{
RelativeSizeAxes = Axes.Both,
Masking = true,
},
};
}
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 BeatmapSelectionPanel(item)
{
Size = new Vector2(300, 70),
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<BeatmapSelectionPanel>();
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] * (BeatmapPanel.SIZE + new Vector2(panel_spacing));
panel.MoveTo(position, duration + stagger * i, new SplitEasingFunction(Easing.InCubic, Easing.OutExpo, 0.3f));
}
}
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++;
BeatmapSelectionPanel? 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];
Scheduler.AddDelayed(() =>
{
lastPanel?.HideBorder();
panel.ShowBorder();
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.ShowBorder();
panel.MoveTo(Vector2.Zero, 1000, Easing.OutExpo)
.ScaleTo(1.5f, 1000, Easing.OutExpo);
});
}
}
internal void PresentUnanimouslyChosenBeatmap(long finalItem)
{
// TODO: display special animation in this case
PresentRolledBeatmap(finalItem);
}
private partial class PanelGridContainer : FillFlowContainer<BeatmapSelectionPanel>
{
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,139 @@
// 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 osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick
{
public partial class BeatmapSelectionOverlay : CompositeDrawable
{
private readonly Dictionary<int, SelectionAvatar> avatars = new Dictionary<int, SelectionAvatar>();
private readonly Container<SelectionAvatar> avatarContainer;
public new Axes AutoSizeAxes
{
get => base.AutoSizeAxes;
set => base.AutoSizeAxes = value;
}
public new MarginPadding Padding
{
get => base.Padding;
set => base.Padding = value;
}
public BeatmapSelectionOverlay()
{
InternalChild = avatarContainer = new Container<SelectionAvatar>();
}
protected override void LoadComplete()
{
base.LoadComplete();
avatarContainer.AutoSizeAxes = AutoSizeAxes;
avatarContainer.RelativeSizeAxes = RelativeSizeAxes;
}
public bool AddUser(APIUser user, bool isOwnUser)
{
if (avatars.ContainsKey(user.Id))
return false;
var avatar = new SelectionAvatar(user, isOwnUser)
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
};
avatarContainer.Add(avatars[user.Id] = avatar);
updateLayout();
avatar.FinishTransforms();
return true;
}
public bool RemoveUser(int id)
{
if (!avatars.Remove(id, out var avatar))
return false;
avatar.PopOutAndExpire();
avatarContainer.ChangeChildDepth(avatar, float.MaxValue);
updateLayout();
return true;
}
private void updateLayout()
{
const double stagger = 30;
const float spacing = 4;
double delay = 0;
float x = 0;
for (int i = avatarContainer.Count - 1; i >= 0; i--)
{
var avatar = avatarContainer[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 bool Expired { get; private set; }
private readonly Container content;
public SelectionAvatar(APIUser user, bool isOwnUser)
{
Size = new Vector2(30);
InternalChildren = new Drawable[]
{
content = new Container
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = new MatchmakingAvatar(user, isOwnUser)
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
content.ScaleTo(0)
.ScaleTo(1, 500, Easing.OutElasticHalf)
.FadeIn(200);
}
public void PopOutAndExpire()
{
content.ScaleTo(0, 400, Easing.OutExpo);
this.FadeOut(100).Expire();
Expired = true;
}
}
}
}
@@ -0,0 +1,213 @@
// 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.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick
{
public partial class BeatmapSelectionPanel : Container
{
private const float corner_radius = 6;
private const float border_width = 3;
public readonly MultiplayerPlaylistItem Item;
private readonly Container scaleContainer;
private readonly BeatmapPanel beatmapPanel;
private readonly BeatmapSelectionOverlay selectionOverlay;
private readonly Container border;
private readonly Box flash;
private readonly Container shadow;
public bool AllowSelection;
public Action<MultiplayerPlaylistItem>? Action;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
public override bool PropagatePositionalInputSubTree => AllowSelection;
public BeatmapSelectionPanel(MultiplayerPlaylistItem item)
{
Item = item;
Size = BeatmapPanel.SIZE;
InternalChildren = new Drawable[]
{
scaleContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
shadow = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(4),
Y = 8,
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 7,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black,
Alpha = 0.15f,
}
}
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(-border_width),
Child = border = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = corner_radius + border_width,
Alpha = 0,
Child = new Box { RelativeSizeAxes = Axes.Both },
}
},
beatmapPanel = new BeatmapPanel
{
RelativeSizeAxes = Axes.Both,
OverlayLayer =
{
Children = new[]
{
flash = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
},
}
}
},
selectionOverlay = new BeatmapSelectionOverlay
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10 },
Origin = Anchor.CentreLeft,
},
}
},
new HoverClickSounds(),
};
}
protected override void LoadComplete()
{
base.LoadComplete();
beatmapLookupCache.GetBeatmapAsync(Item.BeatmapID).ContinueWith(b => Schedule(() =>
{
var beatmap = b.GetResultSafely()!;
beatmap.StarRating = Item.StarRating;
beatmapPanel.Beatmap = beatmap;
}));
}
public bool AddUser(APIUser user, bool isOwnUser = false) => selectionOverlay.AddUser(user, isOwnUser);
public bool RemoveUser(int userId) => selectionOverlay.RemoveUser(userId);
public bool RemoveUser(APIUser user) => RemoveUser(user.Id);
protected override bool OnHover(HoverEvent e)
{
flash.FadeTo(0.2f, 50)
.Then()
.FadeTo(0.1f, 300);
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
flash.FadeOut(200);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (e.Button == MouseButton.Left)
{
scaleContainer.ScaleTo(0.95f, 400, Easing.OutExpo);
shadow.MoveToY(4, 400, Easing.OutExpo)
.TransformTo(nameof(Padding), new MarginPadding(2), 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);
shadow.MoveToY(8, 500, Easing.OutElasticHalf)
.TransformTo(nameof(Padding), new MarginPadding(4), 400, Easing.OutExpo);
}
}
protected override bool OnClick(ClickEvent e)
{
Action?.Invoke(Item);
flash.FadeTo(0.5f, 50)
.Then()
.FadeTo(0.1f, 400);
return true;
}
public void ShowBorder() => border.Show();
public void HideBorder() => border.Hide();
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();
}
}
}
@@ -0,0 +1,83 @@
// 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.Screens.Pick
{
public partial class PickScreen : OsuScreen
{
private BeatmapSelectionGrid selectionGrid = null!;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new Container
{
RelativeSizeAxes = Axes.Both,
Child = selectionGrid = new BeatmapSelectionGrid
{
RelativeSizeAxes = Axes.Both,
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
client.ItemAdded += onItemAdded;
foreach (var item in client.Room!.Playlist)
onItemAdded(item);
selectionGrid.ItemSelected += item => client.MatchmakingToggleSelection(item.ID);
client.MatchmakingItemSelected += onItemSelected;
client.MatchmakingItemDeselected += onItemDeselected;
}
private void onItemAdded(MultiplayerPlaylistItem item) => Scheduler.Add(() =>
{
if (item.Expired)
return;
selectionGrid.AddItem(item);
});
private void onItemSelected(int userId, long itemId)
{
var user = client.Room!.Users.First(it => it.UserID == userId).User!;
selectionGrid.SetUserSelection(user, itemId, true);
}
private void onItemDeselected(int userId, long itemId)
{
var user = client.Room!.Users.First(it => it.UserID == userId).User!;
selectionGrid.SetUserSelection(user, itemId, false);
}
public void RollFinalBeatmap(long[] candidateItems, long finalItem) => selectionGrid.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,345 @@
// 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.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.Screens.OnlinePlay.Matchmaking.Screens.Idle;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results
{
public partial class ResultsScreen : MatchmakingSubScreen
{
private const float grid_spacing = 5;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private OsuSpriteText placementText = null!;
private FillFlowContainer<UserStatisticPanel> userStatistics = null!;
private FillFlowContainer<RoomStatisticPanel> roomStatistics = null!;
[BackgroundDependencyLoader]
private void load()
{
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<UserStatisticPanel>
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(grid_spacing)
}
}
},
null,
new PlayerPanelList
{
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<RoomStatisticPanel>
{
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 UserStatisticPanel(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)
{
MultiplayerRoomUser? user = client.Room?.Users.FirstOrDefault(u => u.UserID == userId);
if (user == null)
throw new InvalidOperationException($"User not found in room: {userId}");
roomStatistics.Add(new RoomStatisticPanel(text, user)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
});
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.MatchRoomStateChanged -= onRoomStateChanged;
}
}
}
@@ -0,0 +1,52 @@
// 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 osu.Game.Online.Multiplayer;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results
{
public partial class RoomStatisticPanel : CompositeDrawable
{
private readonly Color4 backgroundColour = Color4.SaddleBrown;
private readonly string text;
private readonly MultiplayerRoomUser user;
public RoomStatisticPanel(string text, MultiplayerRoomUser user)
{
this.text = text;
this.user = user;
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}: {user.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.Screens.Results
{
public partial class UserStatisticPanel : CompositeDrawable
{
private readonly Color4 backgroundColour = Color4.SaddleBrown;
private readonly string text;
public UserStatisticPanel(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,36 @@
// 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.Graphics.Containers;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults
{
internal partial class RoundResultsScorePanel : CompositeDrawable
{
public RoundResultsScorePanel(ScoreInfo score)
{
AutoSizeAxes = Axes.Both;
InternalChild = new InstantSizingScorePanel(score);
}
public override bool PropagateNonPositionalInputSubTree => false;
public override bool PropagatePositionalInputSubTree => false;
private partial class InstantSizingScorePanel : ScorePanel
{
public InstantSizingScorePanel(ScoreInfo score, bool isNewLocalScore = false)
: base(score, isNewLocalScore)
{
}
protected override void LoadComplete()
{
base.LoadComplete();
FinishTransforms(true);
}
}
}
}
@@ -0,0 +1,181 @@
// 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 System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Models;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults
{
public partial class RoundResultsScreen : MatchmakingSubScreen
{
private const int panel_spacing = 5;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
private AutoScrollContainer scrollContainer = null!;
private LoadingSpinner loadingSpinner = null!;
[BackgroundDependencyLoader]
private void load()
{
InternalChildren = new Drawable[]
{
scrollContainer = new AutoScrollContainer
{
RelativeSizeAxes = Axes.Both
},
loadingSpinner = new LoadingSpinner
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
loadingSpinner.Show();
queryScores().FireAndForget();
}
private async Task queryScores()
{
try
{
if (client.Room == null)
return;
Task<APIBeatmap?> beatmapTask = beatmapLookupCache.GetBeatmapAsync(client.Room.CurrentPlaylistItem.BeatmapID);
TaskCompletionSource<List<MultiplayerScore>> scoreTask = new TaskCompletionSource<List<MultiplayerScore>>();
var request = new IndexPlaylistScoresRequest(client.Room.RoomID, client.Room.Settings.PlaylistItemId);
request.Success += req => scoreTask.SetResult(req.Scores);
request.Failure += e => scoreTask.SetException(e);
api.Queue(request);
await Task.WhenAll(beatmapTask, scoreTask.Task).ConfigureAwait(false);
APIBeatmap? apiBeatmap = beatmapTask.GetResultSafely();
List<MultiplayerScore> apiScores = scoreTask.Task.GetResultSafely();
if (apiBeatmap == null)
return;
// Reference: PlaylistItemResultsScreen
setScores(apiScores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, new BeatmapInfo
{
Difficulty = new BeatmapDifficulty(apiBeatmap.Difficulty),
Metadata =
{
Artist = apiBeatmap.Metadata.Artist,
Title = apiBeatmap.Metadata.Title,
Author = new RealmUser
{
Username = apiBeatmap.Metadata.Author.Username,
OnlineID = apiBeatmap.Metadata.Author.OnlineID,
}
},
DifficultyName = apiBeatmap.DifficultyName,
StarRating = apiBeatmap.StarRating,
Length = apiBeatmap.Length,
BPM = apiBeatmap.BPM
})).ToArray());
}
catch (Exception e)
{
Logger.Error(e, "Failed to load scores for playlist item.");
throw;
}
finally
{
Scheduler.Add(() => loadingSpinner.Hide());
}
}
private void setScores(ScoreInfo[] scores) => Scheduler.Add(() =>
{
Container panels;
scrollContainer.Child = panels = new Container
{
RelativeSizeAxes = Axes.Y,
Width = scores.Length * (ScorePanel.CONTRACTED_WIDTH + panel_spacing),
ChildrenEnumerable = scores.Select(s => new RoundResultsScorePanel(s)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
})
};
for (int i = 0; i < panels.Count; i++)
{
panels[i].MoveToX(panels.DrawWidth * 2)
.Delay(i * 100)
.MoveToX((ScorePanel.CONTRACTED_WIDTH + panel_spacing) * i, 500, Easing.OutQuint);
}
});
private partial class AutoScrollContainer : UserTrackingScrollContainer
{
private const float initial_offset = -0.5f;
private const double scroll_duration = 20000;
private double? scrollStartTime;
public AutoScrollContainer()
: base(Direction.Horizontal)
{
}
protected override void Update()
{
base.Update();
if (!UserScrolling && Children.Count > 0)
{
scrollStartTime ??= Time.Current;
double scrollOffset = (Time.Current - scrollStartTime.Value) / scroll_duration;
if (scrollOffset < 1)
ScrollTo(DrawWidth * (initial_offset + scrollOffset), false);
}
}
}
}
}
@@ -0,0 +1,157 @@
// 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.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Matchmaking
{
internal partial class StageBubble : CompositeDrawable
{
private readonly Color4 backgroundColour = Color4.Salmon;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private readonly MatchmakingStage stage;
private readonly LocalisableString displayText;
private Drawable progressBar = null!;
private DateTimeOffset countdownStartTime;
private DateTimeOffset countdownEndTime;
public StageBubble(MatchmakingStage stage, LocalisableString displayText)
{
this.stage = stage;
this.displayText = displayText;
AutoSizeAxes = Axes.Y;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new CircularContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Masking = true,
Children = new[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = backgroundColour.Darken(0.2f)
},
progressBar = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = backgroundColour
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = displayText,
Padding = new MarginPadding(10)
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
client.MatchRoomStateChanged += onMatchRoomStateChanged;
client.CountdownStarted += onCountdownStarted;
client.CountdownStopped += onCountdownStopped;
if (client.Room != null)
{
onMatchRoomStateChanged(client.Room.MatchState);
foreach (var countdown in client.Room.ActiveCountdowns)
onCountdownStarted(countdown);
}
}
protected override void Update()
{
base.Update();
TimeSpan duration = countdownEndTime - countdownStartTime;
if (duration.TotalMilliseconds == 0)
progressBar.Width = 0;
else
{
TimeSpan elapsed = DateTimeOffset.Now - countdownStartTime;
progressBar.Width = (float)(elapsed.TotalMilliseconds / duration.TotalMilliseconds);
}
}
private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
{
if (state is not MatchmakingRoomState matchmakingState)
return;
if (matchmakingState.Stage == MatchmakingStage.RoundWarmupTime)
{
countdownStartTime = countdownEndTime = DateTimeOffset.Now;
activate();
}
});
private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() =>
{
if (countdown is not MatchmakingStageCountdown matchmakingStatusCountdown || matchmakingStatusCountdown.Stage != stage)
return;
countdownStartTime = DateTimeOffset.Now;
countdownEndTime = countdownStartTime + countdown.TimeRemaining;
activate();
});
private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() =>
{
if (countdown is not MatchmakingStageCountdown matchmakingStatusCountdown || matchmakingStatusCountdown.Stage != stage)
return;
countdownEndTime = DateTimeOffset.Now;
deactivate();
});
private void activate()
{
this.FadeTo(1, 200);
}
private void deactivate()
{
this.FadeTo(0.5f, 200);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.MatchRoomStateChanged -= onMatchRoomStateChanged;
client.CountdownStarted -= onCountdownStarted;
client.CountdownStopped -= onCountdownStopped;
}
}
}
}
@@ -0,0 +1,91 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking
{
public partial class StageDisplay : CompositeDrawable
{
public static readonly (MatchmakingStage status, LocalisableString text)[] DISPLAYED_STAGES =
[
(MatchmakingStage.RoundWarmupTime, "Next Round"),
(MatchmakingStage.UserBeatmapSelect, "Beatmap Selection"),
(MatchmakingStage.GameplayWarmupTime, "Get Ready"),
(MatchmakingStage.ResultsDisplaying, "Results"),
(MatchmakingStage.Ended, "Match End")
];
public StageDisplay()
{
AutoSizeAxes = Axes.Y;
}
[BackgroundDependencyLoader]
private void load()
{
List<Dimension> columnDimensions = new List<Dimension>();
List<Drawable> columnContent = new List<Drawable>();
for (int i = 0; i < DISPLAYED_STAGES.Length; i++)
{
if (i > 0)
{
columnDimensions.Add(new Dimension(GridSizeMode.AutoSize));
columnContent.Add(new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(16),
Icon = FontAwesome.Solid.ArrowRight,
Margin = new MarginPadding { Horizontal = 10 }
});
}
columnDimensions.Add(new Dimension());
columnContent.Add(new StageBubble(DISPLAYED_STAGES[i].status, DISPLAYED_STAGES[i].text)
{
RelativeSizeAxes = Axes.X
});
}
InternalChild = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RowDimensions =
[
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize)
],
Content = new Drawable[][]
{
[
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = columnDimensions.ToArray(),
RowDimensions = [new Dimension(GridSizeMode.AutoSize)],
Content = new[] { columnContent.ToArray() }
}
],
[
new StageText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
}
]
}
};
}
}
}
@@ -0,0 +1,84 @@
// 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.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
namespace osu.Game.Screens.OnlinePlay.Matchmaking
{
public partial class StageText : CompositeDrawable
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private OsuSpriteText text = null!;
public StageText()
{
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = text = new OsuSpriteText
{
Height = 16,
Font = OsuFont.Default,
AlwaysPresent = true,
};
}
protected override void LoadComplete()
{
base.LoadComplete();
client.MatchRoomStateChanged += onMatchRoomStateChanged;
onMatchRoomStateChanged(client.Room!.MatchState);
}
private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() =>
{
if (state is not MatchmakingRoomState matchmakingState)
return;
text.Text = getTextForStatus(matchmakingState.Stage);
});
private LocalisableString getTextForStatus(MatchmakingStage status)
{
switch (status)
{
case MatchmakingStage.WaitingForClientsJoin:
return "Players are joining the match...";
case MatchmakingStage.WaitingForClientsBeatmapDownload:
return "Players are downloading the beatmap...";
case MatchmakingStage.Gameplay:
return "Game is in progress...";
case MatchmakingStage.Ended:
return "Thanks for playing! The match will close shortly.";
default:
return string.Empty;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.MatchRoomStateChanged -= onMatchRoomStateChanged;
}
}
}
@@ -14,6 +14,7 @@ using osu.Game.Beatmaps;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
@@ -73,6 +74,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
private long lastPlaylistItemId;
private int lastCountdownId;
private readonly Dictionary<int, long> matchmakingUserPicks = new Dictionary<int, long>();
private readonly TestRoomRequestsHandler apiRequestHandler;
public TestMultiplayerClient(TestRoomRequestsHandler? apiRequestHandler = null)
@@ -409,22 +412,43 @@ namespace osu.Game.Tests.Visual.Multiplayer
break;
case StartMatchCountdownRequest startCountdown:
ServerRoom.ActiveCountdowns.Add(new MatchStartCountdown
{
ID = ++lastCountdownId,
TimeRemaining = startCountdown.Duration
});
await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStartedEvent(ServerRoom.ActiveCountdowns[^1]))).ConfigureAwait(false);
await StartCountdown(new MatchStartCountdown { TimeRemaining = startCountdown.Duration }).ConfigureAwait(false);
break;
case StopCountdownRequest stopCountdown:
ServerRoom.ActiveCountdowns.Remove(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID));
await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStoppedEvent(stopCountdown.ID))).ConfigureAwait(false);
await StopCountdown(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)).ConfigureAwait(false);
break;
}
}
public async Task StartCountdown(MultiplayerCountdown countdown)
{
countdown.ID = ++lastCountdownId;
countdown = clone(countdown);
Debug.Assert(ServerRoom != null);
Debug.Assert(LocalUser != null);
if (countdown.IsExclusive)
{
MultiplayerCountdown? existingCountdown = ServerRoom.ActiveCountdowns.FirstOrDefault(c => c.GetType() == countdown.GetType());
if (existingCountdown != null)
await StopCountdown(existingCountdown).ConfigureAwait(false);
}
ServerRoom.ActiveCountdowns.Add(countdown);
await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStartedEvent(ServerRoom.ActiveCountdowns[^1]))).ConfigureAwait(false);
}
public async Task StopCountdown(MultiplayerCountdown countdown)
{
Debug.Assert(ServerRoom != null);
Debug.Assert(LocalUser != null);
ServerRoom.ActiveCountdowns.Remove(ServerRoom.ActiveCountdowns.First(c => c.ID == countdown.ID));
await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStoppedEvent(countdown.ID))).ConfigureAwait(false);
}
public override Task StartMatch()
{
Debug.Assert(ServerRoom != null);
@@ -718,6 +742,66 @@ namespace osu.Game.Tests.Visual.Multiplayer
return Task.CompletedTask;
}
public async Task ChangeMatchRoomState(MatchRoomState state)
{
Debug.Assert(ServerRoom != null);
ServerRoom.MatchState = state;
await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false);
}
public override Task MatchmakingJoinLobby()
{
return Task.CompletedTask;
}
public override Task MatchmakingLeaveLobby()
{
return Task.CompletedTask;
}
public override async Task MatchmakingJoinQueue(MatchmakingSettings settings)
{
await ((IMultiplayerClient)this).MatchmakingQueueJoined().ConfigureAwait(false);
await ((IMultiplayerClient)this).MatchmakingQueueStatusChanged(new MatchmakingQueueStatus.Searching()).ConfigureAwait(false);
}
public override async Task MatchmakingLeaveQueue()
{
await ((IMultiplayerClient)this).MatchmakingQueueLeft().ConfigureAwait(false);
}
public override Task MatchmakingAcceptInvitation()
{
return Task.CompletedTask;
}
public override Task MatchmakingDeclineInvitation()
{
return Task.CompletedTask;
}
public override Task MatchmakingToggleSelection(long playlistItemId)
=> MatchmakingToggleUserSelection(api.LocalUser.Value.OnlineID, playlistItemId);
public override Task MatchmakingSkipToNextStage()
=> Task.CompletedTask;
public async Task MatchmakingToggleUserSelection(int userId, long playlistItemId)
{
if (matchmakingUserPicks.TryGetValue(userId, out long existingId))
{
if (existingId == playlistItemId)
return;
await ((IMultiplayerClient)this).MatchmakingItemDeselected(clone(userId), clone(existingId)).ConfigureAwait(false);
}
matchmakingUserPicks[userId] = playlistItemId;
await ((IMultiplayerClient)this).MatchmakingItemSelected(clone(userId), clone(playlistItemId)).ConfigureAwait(false);
}
#region API Room Handling
public IReadOnlyList<Room> ServerSideRooms