// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Spectator; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { [LongRunningLoad] public class MultiplayerGameplayLeaderboard : GameplayLeaderboard { protected readonly Dictionary UserScores = new Dictionary(); public readonly SortedDictionary TeamScores = new SortedDictionary(); [Resolved] private OsuColour colours { get; set; } [Resolved] private SpectatorClient spectatorClient { get; set; } [Resolved] private MultiplayerClient multiplayerClient { get; set; } [Resolved] private UserLookupCache userLookupCache { get; set; } private readonly RulesetInfo ruleset; private readonly ScoreProcessor scoreProcessor; private readonly MultiplayerRoomUser[] playingUsers; private Bindable scoringMode; private readonly IBindableList playingUserIds = new BindableList(); private bool hasTeams => TeamScores.Count > 0; /// /// Construct a new leaderboard. /// /// The ruleset. /// A score processor instance to handle score calculation for scores of users in the match. /// IDs of all users in this match. public MultiplayerGameplayLeaderboard(RulesetInfo ruleset, ScoreProcessor scoreProcessor, MultiplayerRoomUser[] users) { // todo: this will eventually need to be created per user to support different mod combinations. this.ruleset = ruleset; this.scoreProcessor = scoreProcessor; playingUsers = users; } [BackgroundDependencyLoader] private void load(OsuConfigManager config, IAPIProvider api) { scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); foreach (var user in playingUsers) { var trackedUser = CreateUserData(user, ruleset, scoreProcessor); trackedUser.ScoringMode.BindTo(scoringMode); trackedUser.Score.BindValueChanged(_ => Scheduler.AddOnce(updateTotals)); UserScores[user.UserID] = trackedUser; if (trackedUser.Team is int team && !TeamScores.ContainsKey(team)) TeamScores.Add(team, new BindableInt()); } userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray()).ContinueWith(task => Schedule(() => { var users = task.GetResultSafely(); for (int i = 0; i < users.Length; i++) { var user = users[i] ??= new APIUser { Id = playingUsers[i].UserID, Username = "Unknown user", }; var trackedUser = UserScores[user.Id]; var leaderboardScore = Add(user, user.Id == api.LocalUser.Value.Id); leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy); leaderboardScore.TotalScore.BindTo(trackedUser.Score); leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo); leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); } })); } protected override void LoadComplete() { base.LoadComplete(); // BindableList handles binding in a really bad way (Clear then AddRange) so we need to do this manually.. foreach (var user in playingUsers) { spectatorClient.WatchUser(user.UserID); if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(user.UserID)) usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { user.UserID })); } // bind here is to support players leaving the match. // new players are not supported. playingUserIds.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); playingUserIds.BindCollectionChanged(usersChanged); // this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer). spectatorClient.OnNewFrames += handleIncomingFrames; } protected virtual TrackedUserData CreateUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) => new TrackedUserData(user, ruleset, scoreProcessor); protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) { var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); if (UserScores[user.Id].Team is int team) { leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); leaderboardScore.TextColour = Color4.White; } return leaderboardScore; } private Color4 getTeamColour(int team) { switch (team) { case 0: return colours.TeamColourRed; default: return colours.TeamColourBlue; } } private void usersChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Remove: foreach (int userId in e.OldItems.OfType()) { spectatorClient.StopWatchingUser(userId); if (UserScores.TryGetValue(userId, out var trackedData)) trackedData.MarkUserQuit(); } break; } } private void handleIncomingFrames(int userId, FrameDataBundle bundle) => Schedule(() => { if (!UserScores.TryGetValue(userId, out var trackedData)) return; trackedData.Frames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); trackedData.UpdateScore(); }); private void updateTotals() { if (!hasTeams) return; foreach (var scores in TeamScores.Values) scores.Value = 0; foreach (var u in UserScores.Values) { if (u.Team == null) continue; if (TeamScores.TryGetValue(u.Team.Value, out var team)) team.Value += (int)Math.Round(u.Score.Value); } } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (spectatorClient != null) { foreach (var user in playingUsers) { spectatorClient.StopWatchingUser(user.UserID); } spectatorClient.OnNewFrames -= handleIncomingFrames; } } protected class TrackedUserData { public readonly MultiplayerRoomUser User; public readonly ScoreProcessor ScoreProcessor; public readonly BindableDouble Score = new BindableDouble(); public readonly BindableDouble Accuracy = new BindableDouble(1); public readonly BindableInt CurrentCombo = new BindableInt(); public readonly BindableBool UserQuit = new BindableBool(); public readonly IBindable ScoringMode = new Bindable(); public readonly List Frames = new List(); public int? Team => (User.MatchState as TeamVersusUserState)?.TeamID; private readonly ScoreInfo scoreInfo; public TrackedUserData(MultiplayerRoomUser user, RulesetInfo ruleset, ScoreProcessor scoreProcessor) { User = user; ScoreProcessor = scoreProcessor; scoreInfo = new ScoreInfo { Ruleset = ruleset }; ScoringMode.BindValueChanged(_ => UpdateScore()); } public void MarkUserQuit() => UserQuit.Value = true; public virtual void UpdateScore() { if (Frames.Count == 0) return; SetFrame(Frames.Last()); } protected void SetFrame(TimedFrame frame) { var header = frame.Header; scoreInfo.MaxCombo = header.MaxCombo; scoreInfo.Statistics = header.Statistics; Score.Value = ScoreProcessor.ComputePartialScore(ScoringMode.Value, scoreInfo); Accuracy.Value = header.Accuracy; CurrentCombo.Value = header.Combo; } } protected class TimedFrame : IComparable { public readonly double Time; public readonly FrameHeader Header; public TimedFrame(double time) { Time = time; } public TimedFrame(double time, FrameHeader header) { Time = time; Header = header; } public int CompareTo(TimedFrame other) => Time.CompareTo(other.Time); } } }