1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-13 16:13:34 +08:00

Merge branch 'master' into catch-playfield

This commit is contained in:
Salman Ahmed 2024-02-15 08:08:01 +03:00 committed by GitHub
commit 02bcd29303
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1278 additions and 484 deletions

View File

@ -1,111 +0,0 @@
// 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.
#nullable disable
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Skinning;
using osu.Game.Tests.Visual;
using Direction = osu.Game.Rulesets.Catch.UI.Direction;
namespace osu.Game.Rulesets.Catch.Tests
{
public partial class TestSceneCatchSkinConfiguration : OsuTestScene
{
private Catcher catcher;
private readonly Container container;
public TestSceneCatchSkinConfiguration()
{
Add(container = new Container { RelativeSizeAxes = Axes.Both });
}
[TestCase(false)]
[TestCase(true)]
public void TestCatcherPlateFlipping(bool flip)
{
AddStep("setup catcher", () =>
{
var skin = new TestSkin { FlipCatcherPlate = flip };
container.Child = new SkinProvidingContainer(skin)
{
Child = catcher = new Catcher(new DroppedObjectContainer())
{
Anchor = Anchor.Centre
}
};
});
Fruit fruit = new Fruit();
AddStep("catch fruit", () => catchFruit(fruit, 20));
float position = 0;
AddStep("record fruit position", () => position = getCaughtObjectPosition(fruit));
AddStep("face left", () => catcher.VisualDirection = Direction.Left);
if (flip)
AddAssert("fruit position changed", () => !Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
else
AddAssert("fruit position unchanged", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
AddStep("face right", () => catcher.VisualDirection = Direction.Right);
AddAssert("fruit position restored", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
}
private float getCaughtObjectPosition(Fruit fruit)
{
var caughtObject = catcher.ChildrenOfType<CaughtObject>().Single(c => c.HitObject == fruit);
return caughtObject.Parent!.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X;
}
private void catchFruit(Fruit fruit, float x)
{
fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
var drawableFruit = new DrawableFruit(fruit) { X = x };
var judgement = fruit.CreateJudgement();
catcher.OnNewResult(drawableFruit, new CatchJudgementResult(fruit, judgement)
{
Type = judgement.MaxResult
});
}
private class TestSkin : TrianglesSkin
{
public bool FlipCatcherPlate { get; set; }
public TestSkin()
: base(null!)
{
}
public override IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
{
if (lookup is CatchSkinConfiguration config)
{
if (config == CatchSkinConfiguration.FlipCatcherPlate)
return SkinUtils.As<TValue>(new Bindable<bool>(FlipCatcherPlate));
}
return base.GetConfig<TLookup, TValue>(lookup);
}
}
}
}

View File

@ -1,13 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Rulesets.Catch.Skinning
{
public enum CatchSkinConfiguration
{
/// <summary>
/// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
/// </summary>
FlipCatcherPlate
}
}

View File

@ -122,19 +122,6 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
result.Value = LegacyColourCompatibility.DisallowZeroAlpha(result.Value);
return (IBindable<TValue>)result;
case CatchSkinConfiguration config:
switch (config)
{
case CatchSkinConfiguration.FlipCatcherPlate:
// Don't flip catcher plate contents if the catcher is provided by this legacy skin.
if (GetDrawableComponent(new CatchSkinComponentLookup(CatchSkinComponents.Catcher)) != null)
return (IBindable<TValue>)new Bindable<bool>();
break;
}
break;
}
return base.GetConfig<TLookup, TValue>(lookup);

View File

@ -112,11 +112,6 @@ namespace osu.Game.Rulesets.Catch.UI
public Vector2 BodyScale => Scale * body.Scale;
/// <summary>
/// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
/// </summary>
private bool flipCatcherPlate;
/// <summary>
/// Width of the area that can be used to attempt catches during gameplay.
/// </summary>
@ -339,8 +334,6 @@ namespace osu.Game.Rulesets.Catch.UI
skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDash)?.Value ??
DEFAULT_HYPER_DASH_COLOUR;
flipCatcherPlate = skin.GetConfig<CatchSkinConfiguration, bool>(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true;
runHyperDashStateTransition(HyperDashing);
}
@ -352,8 +345,7 @@ namespace osu.Game.Rulesets.Catch.UI
body.Scale = scaleFromDirection;
// Inverse of catcher scale is applied here, as catcher gets scaled by circle size and so do the incoming fruit.
caughtObjectContainer.Scale = (1 / Scale.X) * (flipCatcherPlate ? scaleFromDirection : Vector2.One);
hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One;
caughtObjectContainer.Scale = new Vector2(1 / Scale.X);
// Correct overshooting.
if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) ||

View File

@ -1,4 +1,4 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// 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;
@ -38,12 +38,18 @@ namespace osu.Game.Rulesets.Osu.Mods
private ReplayState<OsuAction> state = null!;
private double lastStateChangeTime;
private DrawableOsuRuleset ruleset = null!;
private IPressHandler pressHandler = null!;
private bool hasReplay;
private bool legacyReplay;
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
ruleset = (DrawableOsuRuleset)drawableRuleset;
// grab the input manager for future use.
osuInputManager = ((DrawableOsuRuleset)drawableRuleset).KeyBindingInputManager;
osuInputManager = ruleset.KeyBindingInputManager;
}
public void ApplyToPlayer(Player player)
@ -51,15 +57,22 @@ namespace osu.Game.Rulesets.Osu.Mods
if (osuInputManager.ReplayInputHandler != null)
{
hasReplay = true;
Debug.Assert(ruleset.ReplayScore != null);
legacyReplay = ruleset.ReplayScore.ScoreInfo.IsLegacyScore;
pressHandler = legacyReplay ? new LegacyReplayPressHandler(this) : new PressHandler(this);
return;
}
pressHandler = new PressHandler(this);
osuInputManager.AllowGameplayInputs = false;
}
public void Update(Playfield playfield)
{
if (hasReplay)
if (hasReplay && !legacyReplay)
return;
bool requiresHold = false;
@ -132,11 +145,62 @@ namespace osu.Game.Rulesets.Osu.Mods
if (down)
{
state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton);
pressHandler.HandlePress(wasLeft);
wasLeft = !wasLeft;
}
else
{
pressHandler.HandleRelease(wasLeft);
}
}
}
state.Apply(osuInputManager.CurrentState, osuInputManager);
private interface IPressHandler
{
void HandlePress(bool wasLeft);
void HandleRelease(bool wasLeft);
}
private class PressHandler : IPressHandler
{
private readonly OsuModRelax mod;
public PressHandler(OsuModRelax mod)
{
this.mod = mod;
}
public void HandlePress(bool wasLeft)
{
mod.state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton);
mod.state.Apply(mod.osuInputManager.CurrentState, mod.osuInputManager);
}
public void HandleRelease(bool wasLeft)
{
mod.state.Apply(mod.osuInputManager.CurrentState, mod.osuInputManager);
}
}
// legacy replays do not contain key-presses with Relax mod, so they need to be triggered by themselves.
private class LegacyReplayPressHandler : IPressHandler
{
private readonly OsuModRelax mod;
public LegacyReplayPressHandler(OsuModRelax mod)
{
this.mod = mod;
}
public void HandlePress(bool wasLeft)
{
mod.osuInputManager.KeyBindingContainer.TriggerPressed(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton);
}
public void HandleRelease(bool wasLeft)
{
// this intentionally releases right when `wasLeft` is true because `wasLeft` is set at point of press and not at point of release
mod.osuInputManager.KeyBindingContainer.TriggerReleased(wasLeft ? OsuAction.RightButton : OsuAction.LeftButton);
}
}
}

View File

@ -454,5 +454,111 @@ namespace osu.Game.Tests.NonVisual.Filtering
return false;
}
}
private static readonly object[] correct_date_query_examples =
{
new object[] { "600" },
new object[] { "0.5s" },
new object[] { "120m" },
new object[] { "48h120s" },
new object[] { "10y24M" },
new object[] { "10y60d120s" },
new object[] { "0y0M2d" },
new object[] { "1y1M2d" }
};
[Test]
[TestCaseSource(nameof(correct_date_query_examples))]
public void TestValidDateQueries(string dateQuery)
{
string query = $"played<{dateQuery} time";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
}
private static readonly object[] incorrect_date_query_examples =
{
new object[] { ".5s" },
new object[] { "7m27" },
new object[] { "7m7m7m" },
new object[] { "5s6m" },
new object[] { "7d7y" },
new object[] { "0:3:6" },
new object[] { "0:3:" },
new object[] { "\"three days\"" },
new object[] { "0.1y0.1M2d" },
new object[] { "0.99y0.99M2d" },
new object[] { string.Empty }
};
[Test]
[TestCaseSource(nameof(incorrect_date_query_examples))]
public void TestInvalidDateQueries(string dateQuery)
{
string query = $"played<{dateQuery} time";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter);
}
[Test]
public void TestGreaterDateQuery()
{
const string query = "played>50";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null);
Assert.That(filterCriteria.LastPlayed.Min, Is.Null);
// the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
// (irrelevant in proportion to the actual filter proscribed).
Assert.That(filterCriteria.LastPlayed.Max, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void TestLowerDateQuery()
{
const string query = "played<50";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.LastPlayed.Max, Is.Null);
Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null);
// the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
// (irrelevant in proportion to the actual filter proscribed).
Assert.That(filterCriteria.LastPlayed.Min, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void TestBothSidesDateQuery()
{
const string query = "played>3M played<1y6M";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null);
Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null);
// the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
// (irrelevant in proportion to the actual filter proscribed).
Assert.That(filterCriteria.LastPlayed.Min, Is.EqualTo(DateTimeOffset.Now.AddYears(-1).AddMonths(-6)).Within(TimeSpan.FromSeconds(5)));
Assert.That(filterCriteria.LastPlayed.Max, Is.EqualTo(DateTimeOffset.Now.AddMonths(-3)).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void TestEqualDateQuery()
{
const string query = "played=50";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter);
}
[Test]
public void TestOutOfRangeDateQuery()
{
const string query = "played<10000y";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min);
}
}
}

View File

@ -43,13 +43,13 @@ namespace osu.Game.Tests.Rulesets
AddStep("setup provider", () =>
{
var rulesetSkinProvider = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin);
rulesetSkinProvider.Add(requester = new SkinRequester());
requester = new SkinRequester();
requester.OnLoadAsync += () => textureOnLoad = requester.GetTexture("test-image");
Child = rulesetSkinProvider;
Child = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin)
{
requester
};
});
AddAssert("requester got correct initial texture", () => textureOnLoad != null);

View File

@ -2,12 +2,17 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.Solo;
using osu.Game.Overlays.Toolbar;
using osu.Game.Scoring;
using osu.Game.Users;
using osuTK;
using osuTK.Graphics;
@ -87,5 +92,91 @@ namespace osu.Game.Tests.Visual.Menus
AddStep($"Change state to {state}", () => dummyAPI.SetState(state));
}
}
[Test]
public void TestTransientUserStatisticsDisplay()
{
AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Gain", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 123_456,
PP = 1234
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Loss", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = 123_456,
PP = 1234
});
});
AddStep("No change", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Was null", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = null,
PP = null
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Became null", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = null,
PP = null
});
});
}
}
}

View File

@ -986,6 +986,29 @@ namespace osu.Game.Tests.Visual.Navigation
}
}
[Test]
public void TestPresentBeatmapAfterDeletion()
{
BeatmapSetInfo beatmap = null;
Screens.Select.SongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("delete selected beatmap", () =>
{
beatmap = Game.Beatmap.Value.BeatmapSetInfo;
Game.BeatmapManager.Delete(Game.Beatmap.Value.BeatmapSetInfo);
});
AddUntilStep("nothing selected", () => Game.Beatmap.IsDefault);
AddStep("present deleted beatmap", () => Game.PresentBeatmap(beatmap));
AddAssert("still nothing selected", () => Game.Beatmap.IsDefault);
}
private Func<Player> playToResults()
{
var player = playToCompletion();

View File

@ -170,6 +170,24 @@ namespace osu.Game.Tests.Visual.Online
});
}
[Test]
public void TestPostAsOwner()
{
setUpCommentsResponse(getExampleComments());
AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123));
setUpPostResponse(true);
AddStep("enter text", () => editorTextBox.Current.Value = "comm");
AddStep("submit", () => commentsContainer.ChildrenOfType<CommentEditor>().Single().ChildrenOfType<RoundedButton>().First().TriggerClick());
AddUntilStep("comment sent", () =>
{
string writtenText = editorTextBox.Current.Value;
var comment = commentsContainer.ChildrenOfType<DrawableComment>().LastOrDefault();
return comment != null && comment.ChildrenOfType<SpriteText>().Any(y => y.Text == writtenText) && comment.ChildrenOfType<SpriteText>().Any(y => y.Text == "MAPPER");
});
}
private void setUpCommentsResponse(CommentBundle commentBundle)
=> AddStep("set up response", () =>
{
@ -183,7 +201,7 @@ namespace osu.Game.Tests.Visual.Online
};
});
private void setUpPostResponse()
private void setUpPostResponse(bool asOwner = false)
=> AddStep("set up response", () =>
{
dummyAPI.HandleRequest = request =>
@ -191,7 +209,7 @@ namespace osu.Game.Tests.Visual.Online
if (!(request is CommentPostRequest req))
return false;
req.TriggerSuccess(new CommentBundle
var bundle = new CommentBundle
{
Comments = new List<Comment>
{
@ -202,9 +220,26 @@ namespace osu.Game.Tests.Visual.Online
LegacyName = "FirstUser",
CreatedAt = DateTimeOffset.Now,
VotesCount = 98,
CommentableId = 2001,
CommentableType = "test",
}
}
});
};
if (asOwner)
{
bundle.Comments[0].UserId = 1001;
bundle.Comments[0].User = new APIUser { Id = 1001, Username = "FirstUser" };
bundle.CommentableMeta.Add(new CommentableMeta
{
Id = 2001,
OwnerId = 1001,
OwnerTitle = "MAPPER",
Type = "test",
});
}
req.TriggerSuccess(bundle);
return true;
};
});

View File

@ -4,62 +4,66 @@
#nullable disable
using System;
using NUnit.Framework;
using osu.Framework.Allocation;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Comments;
using osu.Game.Tests.Visual.UserInterface;
namespace osu.Game.Tests.Visual.Online
{
public partial class TestSceneDrawableComment : OsuTestScene
public partial class TestSceneDrawableComment : ThemeComparisonTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
private Container container;
[SetUp]
public void SetUp() => Schedule(() =>
public TestSceneDrawableComment()
: base(false)
{
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4,
},
container = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
};
});
[TestCaseSource(nameof(comments))]
public void TestComment(string description, string text)
{
AddStep(description, () =>
{
comment.Pinned = description == "Pinned";
comment.Message = text;
container.Add(new DrawableComment(comment));
});
}
private static readonly Comment comment = new Comment
protected override Drawable CreateContent() => new OsuScrollContainer(Direction.Vertical)
{
Id = 1,
LegacyName = "Test User",
CreatedAt = DateTimeOffset.Now,
VotesCount = 0,
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
ChildrenEnumerable = comments.Select(info =>
{
var comment = new Comment
{
Id = 1,
UserId = 1000,
User = new APIUser { Id = 1000, Username = "Someone" },
CreatedAt = DateTimeOffset.Now,
VotesCount = 0,
Pinned = info[0] == "Pinned",
Message = info[1],
CommentableId = 2001,
CommentableType = "test"
};
return new[]
{
new DrawableComment(comment, Array.Empty<CommentableMeta>()),
new DrawableComment(comment, new[]
{
new CommentableMeta
{
Id = 2001,
OwnerId = comment.UserId,
OwnerTitle = "MAPPER",
Type = "test",
},
new CommentableMeta { Title = "Other Meta" },
}),
};
}).SelectMany(c => c)
}
};
private static object[] comments =
private static readonly string[][] comments =
{
new[] { "Plain", "This is plain comment" },
new[] { "Pinned", "This is pinned comment" },

View File

@ -154,6 +154,19 @@ namespace osu.Game.Tests.Visual.Online
});
}
[Test]
public void TestUnrankedPP()
{
AddStep("Load scores with unranked PP", () =>
{
var allScores = createScores();
allScores.Scores[0].Ranked = false;
allScores.UserScore = createUserBest();
allScores.UserScore.Score.Ranked = false;
scoresContainer.Scores = allScores;
});
}
private ulong onlineID = 1;
private APIScoresCollection createScores()
@ -184,6 +197,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 1234567890,
Accuracy = 1,
Ranked = true,
},
new SoloScoreInfo
{
@ -206,6 +220,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 1234789,
Accuracy = 0.9997,
Ranked = true,
},
new SoloScoreInfo
{
@ -227,6 +242,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 12345678,
Accuracy = 0.9854,
Ranked = true,
},
new SoloScoreInfo
{
@ -247,6 +263,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 1234567,
Accuracy = 0.8765,
Ranked = true,
},
new SoloScoreInfo
{
@ -263,6 +280,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 123456,
Accuracy = 0.6543,
Ranked = true,
},
}
};
@ -309,6 +327,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 123456,
Accuracy = 0.6543,
Ranked = true,
},
Position = 1337,
};

View File

@ -35,8 +35,6 @@ namespace osu.Game.Tests.Visual.Online
private Action<GetUsersRequest>? handleGetUsersRequest;
private Action<GetUserRequest>? handleGetUserRequest;
private IDisposable? subscription;
private readonly Dictionary<(int userId, string rulesetName), UserStatistics> serverSideStatistics = new Dictionary<(int userId, string rulesetName), UserStatistics>();
[SetUpSteps]
@ -252,26 +250,6 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(6_000_000));
}
[Test]
public void TestStatisticsUpdateNotFiredAfterSubscriptionDisposal()
{
int userId = getUserId();
setUpUser(userId);
long scoreId = getScoreId();
var ruleset = new OsuRuleset().RulesetInfo;
SoloStatisticsUpdate? update = null;
registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate);
AddStep("unsubscribe", () => subscription!.Dispose());
feignScoreProcessing(userId, ruleset, 5_000_000);
AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId));
AddWaitStep("wait a bit", 5);
AddAssert("update not received", () => update == null);
}
[Test]
public void TestGlobalStatisticsUpdatedAfterRegistrationAddedAndScoreProcessed()
{
@ -312,13 +290,20 @@ namespace osu.Game.Tests.Visual.Online
}
private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action<SoloStatisticsUpdate> onUpdateReady) =>
AddStep("register for updates", () => subscription = watcher.RegisterForStatisticsUpdateAfter(
new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser())
AddStep("register for updates", () =>
{
watcher.RegisterForStatisticsUpdateAfter(
new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser())
{
Ruleset = rulesetInfo,
OnlineID = scoreId
});
watcher.LatestUpdate.BindValueChanged(update =>
{
Ruleset = rulesetInfo,
OnlineID = scoreId
},
onUpdateReady));
if (update.NewValue?.Score.OnlineID == scoreId)
onUpdateReady.Invoke(update.NewValue);
});
});
private void feignScoreProcessing(int userId, RulesetInfo rulesetInfo, long newTotalScore)
=> AddStep("feign score processing", () => serverSideStatistics[(userId, rulesetInfo.ShortName)] = new UserStatistics { TotalScore = newTotalScore });

View File

@ -40,7 +40,8 @@ namespace osu.Game.Tests.Visual.Online
new APIMod { Acronym = new OsuModHardRock().Acronym },
new APIMod { Acronym = new OsuModDoubleTime().Acronym },
},
Accuracy = 0.9813
Accuracy = 0.9813,
Ranked = true,
};
var secondScore = new SoloScoreInfo
@ -62,7 +63,8 @@ namespace osu.Game.Tests.Visual.Online
new APIMod { Acronym = new OsuModHardRock().Acronym },
new APIMod { Acronym = new OsuModDoubleTime().Acronym },
},
Accuracy = 0.998546
Accuracy = 0.998546,
Ranked = true,
};
var thirdScore = new SoloScoreInfo
@ -79,7 +81,8 @@ namespace osu.Game.Tests.Visual.Online
DifficultyName = "Insane"
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.9726
Accuracy = 0.9726,
Ranked = true,
};
var noPPScore = new SoloScoreInfo
@ -95,7 +98,26 @@ namespace osu.Game.Tests.Visual.Online
DifficultyName = "[4K] Cataclysmic Hypernova"
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879
Accuracy = 0.55879,
Ranked = true,
};
var lovedScore = new SoloScoreInfo
{
Rank = ScoreRank.B,
Beatmap = new APIBeatmap
{
BeatmapSet = new APIBeatmapSet
{
Title = "C18H27NO3(extend)",
Artist = "Team Grimoire",
},
DifficultyName = "[4K] Cataclysmic Hypernova",
Status = BeatmapOnlineStatus.Loved,
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879,
Ranked = true,
};
var unprocessedPPScore = new SoloScoreInfo
@ -112,7 +134,26 @@ namespace osu.Game.Tests.Visual.Online
Status = BeatmapOnlineStatus.Ranked,
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879
Accuracy = 0.55879,
Ranked = true,
};
var unrankedPPScore = new SoloScoreInfo
{
Rank = ScoreRank.B,
Beatmap = new APIBeatmap
{
BeatmapSet = new APIBeatmapSet
{
Title = "C18H27NO3(extend)",
Artist = "Team Grimoire",
},
DifficultyName = "[4K] Cataclysmic Hypernova",
Status = BeatmapOnlineStatus.Ranked,
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879,
Ranked = false,
};
Add(new FillFlowContainer
@ -128,7 +169,9 @@ namespace osu.Game.Tests.Visual.Online
new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(firstScore)),
new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(secondScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(noPPScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(lovedScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unprocessedPPScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unrankedPPScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(firstScore, 0.97)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)),

View File

@ -14,31 +14,39 @@ namespace osu.Game.Tests.Visual.UserInterface
{
public abstract partial class ThemeComparisonTestScene : OsuGridTestScene
{
protected ThemeComparisonTestScene()
: base(1, 2)
private readonly bool showWithoutColourProvider;
protected ThemeComparisonTestScene(bool showWithoutColourProvider = true)
: base(1, showWithoutColourProvider ? 2 : 1)
{
this.showWithoutColourProvider = showWithoutColourProvider;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Cell(0, 0).AddRange(new[]
if (showWithoutColourProvider)
{
new Box
Cell(0, 0).AddRange(new[]
{
RelativeSizeAxes = Axes.Both,
Colour = colours.GreySeaFoam
},
CreateContent()
});
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colours.GreySeaFoam
},
CreateContent()
});
}
}
protected void CreateThemedContent(OverlayColourScheme colourScheme)
{
var colourProvider = new OverlayColourProvider(colourScheme);
Cell(0, 1).Clear();
Cell(0, 1).Add(new DependencyProvidingContainer
int col = showWithoutColourProvider ? 1 : 0;
Cell(0, col).Clear();
Cell(0, col).Add(new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = new (Type, object)[]

View File

@ -0,0 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.Sprites
{
/// <summary>
/// A <see cref="SpriteIcon"/> with a publicly settable tooltip text.
/// </summary>
public partial class SpriteIconWithTooltip : SpriteIcon, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
}

View File

@ -0,0 +1,16 @@
// 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.Cursor;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.Sprites
{
/// <summary>
/// An <see cref="OsuSpriteText"/> with a publicly settable tooltip text.
/// </summary>
internal partial class SpriteTextWithTooltip : OsuSpriteText, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
}

View File

@ -33,7 +33,7 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"votes_count")]
public int VotesCount { get; set; }
[JsonProperty(@"commenatble_type")]
[JsonProperty(@"commentable_type")]
public string CommentableType { get; set; } = null!;
[JsonProperty(@"commentable_id")]

View File

@ -11,6 +11,9 @@ namespace osu.Game.Online.API.Requests.Responses
{
public class CommentBundle
{
[JsonProperty(@"commentable_meta")]
public List<CommentableMeta> CommentableMeta { get; set; } = new List<CommentableMeta>();
[JsonProperty(@"comments")]
public List<Comment> Comments { get; set; }

View File

@ -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 Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class CommentableMeta
{
[JsonProperty("id")]
public long Id { get; set; }
[JsonProperty("owner_id")]
public long? OwnerId { get; set; }
[JsonProperty("owner_title")]
public string? OwnerTitle { get; set; }
[JsonProperty("title")]
public string Title { get; set; } = string.Empty;
[JsonProperty("type")]
public string Type { get; set; } = string.Empty;
[JsonProperty("url")]
public string Url { get; set; } = string.Empty;
}
}

View File

@ -115,6 +115,9 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty("has_replay")]
public bool HasReplay { get; set; }
[JsonProperty("ranked")]
public bool Ranked { get; set; }
// These properties are calculated or not relevant to any external usage.
public bool ShouldSerializeID() => false;
public bool ShouldSerializeUser() => false;
@ -213,6 +216,7 @@ namespace osu.Game.Online.API.Requests.Responses
HasOnlineReplay = HasReplay,
Mods = mods,
PP = PP,
Ranked = Ranked,
};
if (beatmap is BeatmapInfo realmBeatmap)

View File

@ -1,10 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Extensions;
@ -22,14 +22,16 @@ namespace osu.Game.Online.Solo
/// </summary>
public partial class SoloStatisticsWatcher : Component
{
public IBindable<SoloStatisticsUpdate?> LatestUpdate => latestUpdate;
private readonly Bindable<SoloStatisticsUpdate?> latestUpdate = new Bindable<SoloStatisticsUpdate?>();
[Resolved]
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private readonly Dictionary<long, StatisticsUpdateCallback> callbacks = new Dictionary<long, StatisticsUpdateCallback>();
private long? lastProcessedScoreId;
private readonly Dictionary<long, ScoreInfo> watchedScores = new Dictionary<long, ScoreInfo>();
private Dictionary<string, UserStatistics>? latestStatistics;
@ -45,9 +47,7 @@ namespace osu.Game.Online.Solo
/// Registers for a user statistics update after the given <paramref name="score"/> has been processed server-side.
/// </summary>
/// <param name="score">The score to listen for the statistics update for.</param>
/// <param name="onUpdateReady">The callback to be invoked once the statistics update has been prepared.</param>
/// <returns>An <see cref="IDisposable"/> representing the subscription. Disposing it is equivalent to unsubscribing from future notifications.</returns>
public IDisposable RegisterForStatisticsUpdateAfter(ScoreInfo score, Action<SoloStatisticsUpdate> onUpdateReady)
public void RegisterForStatisticsUpdateAfter(ScoreInfo score)
{
Schedule(() =>
{
@ -57,24 +57,12 @@ namespace osu.Game.Online.Solo
if (!score.Ruleset.IsLegacyRuleset() || score.OnlineID <= 0)
return;
var callback = new StatisticsUpdateCallback(score, onUpdateReady);
if (lastProcessedScoreId == score.OnlineID)
{
requestStatisticsUpdate(api.LocalUser.Value.Id, callback);
return;
}
callbacks.Add(score.OnlineID, callback);
watchedScores.Add(score.OnlineID, score);
});
return new InvokeOnDisposal(() => Schedule(() => callbacks.Remove(score.OnlineID)));
}
private void onUserChanged(APIUser? localUser) => Schedule(() =>
{
callbacks.Clear();
lastProcessedScoreId = null;
latestStatistics = null;
if (localUser == null || localUser.OnlineID <= 1)
@ -107,25 +95,22 @@ namespace osu.Game.Online.Solo
if (userId != api.LocalUser.Value?.OnlineID)
return;
lastProcessedScoreId = scoreId;
if (!callbacks.TryGetValue(scoreId, out var callback))
if (!watchedScores.Remove(scoreId, out var scoreInfo))
return;
requestStatisticsUpdate(userId, callback);
callbacks.Remove(scoreId);
requestStatisticsUpdate(userId, scoreInfo);
}
private void requestStatisticsUpdate(int userId, StatisticsUpdateCallback callback)
private void requestStatisticsUpdate(int userId, ScoreInfo scoreInfo)
{
var request = new GetUserRequest(userId, callback.Score.Ruleset);
request.Success += user => Schedule(() => dispatchStatisticsUpdate(callback, user.Statistics));
var request = new GetUserRequest(userId, scoreInfo.Ruleset);
request.Success += user => Schedule(() => dispatchStatisticsUpdate(scoreInfo, user.Statistics));
api.Queue(request);
}
private void dispatchStatisticsUpdate(StatisticsUpdateCallback callback, UserStatistics updatedStatistics)
private void dispatchStatisticsUpdate(ScoreInfo scoreInfo, UserStatistics updatedStatistics)
{
string rulesetName = callback.Score.Ruleset.ShortName;
string rulesetName = scoreInfo.Ruleset.ShortName;
api.UpdateStatistics(updatedStatistics);
@ -135,9 +120,7 @@ namespace osu.Game.Online.Solo
latestStatistics.TryGetValue(rulesetName, out UserStatistics? latestRulesetStatistics);
latestRulesetStatistics ??= new UserStatistics();
var update = new SoloStatisticsUpdate(callback.Score, latestRulesetStatistics, updatedStatistics);
callback.OnUpdateReady.Invoke(update);
latestUpdate.Value = new SoloStatisticsUpdate(scoreInfo, latestRulesetStatistics, updatedStatistics);
latestStatistics[rulesetName] = updatedStatistics;
}
@ -148,17 +131,5 @@ namespace osu.Game.Online.Solo
base.Dispose(isDisposing);
}
private class StatisticsUpdateCallback
{
public ScoreInfo Score { get; }
public Action<SoloStatisticsUpdate> OnUpdateReady { get; }
public StatisticsUpdateCallback(ScoreInfo score, Action<SoloStatisticsUpdate> onUpdateReady)
{
Score = score;
OnUpdateReady = onUpdateReady;
}
}
}
}

View File

@ -47,6 +47,7 @@ using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.Chat;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Overlays.Music;
@ -630,6 +631,12 @@ namespace osu.Game
var detachedSet = databasedSet.PerformRead(s => s.Detach());
if (detachedSet.DeletePending)
{
Logger.Log("The requested beatmap has since been deleted.", LoggingTarget.Information);
return;
}
PerformFromScreen(screen =>
{
// Find beatmaps that match our predicate.
@ -1015,6 +1022,7 @@ namespace osu.Game
ScreenStack.Push(CreateLoader().With(l => l.RelativeSizeAxes = Axes.Both));
});
loadComponentSingleFile(new SoloStatisticsWatcher(), Add, true);
loadComponentSingleFile(Toolbar = new Toolbar
{
OnHome = delegate

View File

@ -50,7 +50,6 @@ using osu.Game.Online.API;
using osu.Game.Online.Chat;
using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Solo;
using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
@ -207,7 +206,6 @@ namespace osu.Game
protected MultiplayerClient MultiplayerClient { get; private set; }
private MetadataClient metadataClient;
private SoloStatisticsWatcher soloStatisticsWatcher;
private RealmAccess realm;
@ -328,7 +326,6 @@ namespace osu.Game
dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints));
dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints));
dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints));
dependencies.CacheAs(soloStatisticsWatcher = new SoloStatisticsWatcher());
base.Content.Add(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient));
@ -371,7 +368,6 @@ namespace osu.Game
base.Content.Add(SpectatorClient);
base.Content.Add(MultiplayerClient);
base.Content.Add(metadataClient);
base.Content.Add(soloStatisticsWatcher);
base.Content.Add(rulesetConfigCache);

View File

@ -23,9 +23,9 @@ using osuTK.Graphics;
using osu.Framework.Localisation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring.Drawables;
namespace osu.Game.Overlays.BeatmapSet.Scores
{
@ -180,10 +180,26 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
if (showPerformancePoints)
{
if (score.PP != null)
content.Add(new StatisticText(score.PP, format: @"N0"));
if (!score.Ranked)
{
content.Add(new SpriteTextWithTooltip
{
Text = "-",
Font = OsuFont.GetFont(size: text_size),
TooltipText = ScoresStrings.StatusNoPp
});
}
else if (score.PP == null)
{
content.Add(new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Sync,
Size = new Vector2(text_size),
TooltipText = ScoresStrings.StatusProcessing,
});
}
else
content.Add(new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(text_size) });
content.Add(new StatisticText(score.PP, format: @"N0"));
}
content.Add(new ScoreboardTime(score.Date, text_size)

View File

@ -22,7 +22,6 @@ using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Scoring.Drawables;
using osuTK;
namespace osu.Game.Overlays.BeatmapSet.Scores
@ -125,10 +124,26 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
ppColumn.Alpha = value.BeatmapInfo!.Status.GrantsPerformancePoints() ? 1 : 0;
if (value.PP is double pp)
ppColumn.Text = pp.ToLocalisableString(@"N0");
if (!value.Ranked)
{
ppColumn.Drawable = new SpriteTextWithTooltip
{
Text = "-",
Font = smallFont,
TooltipText = ScoresStrings.StatusNoPp
};
}
else if (value.PP is not double pp)
{
ppColumn.Drawable = new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Sync,
Size = new Vector2(smallFont.Size),
TooltipText = ScoresStrings.StatusProcessing,
};
}
else
ppColumn.Drawable = new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(smallFont.Size) };
ppColumn.Text = pp.ToLocalisableString(@"N0");
statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn);
modsColumn.Mods = value.Mods;

View File

@ -0,0 +1,180 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Comments
{
public partial class CommentAuthorLine : FillFlowContainer
{
private readonly Comment comment;
private readonly IReadOnlyList<CommentableMeta> meta;
private OsuSpriteText deletedLabel = null!;
public CommentAuthorLine(Comment comment, IReadOnlyList<CommentableMeta> meta)
{
this.comment = comment;
this.meta = meta;
}
[BackgroundDependencyLoader]
private void load()
{
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(4, 0);
Add(new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold))
{
AutoSizeAxes = Axes.Both
}.With(username =>
{
if (comment.UserId.HasValue)
username.AddUserLink(comment.User);
else
username.AddText(comment.LegacyName!);
}));
var ownerMeta = meta.FirstOrDefault(m => m.Id == comment.CommentableId && m.Type == comment.CommentableType);
if (ownerMeta?.OwnerId != null && ownerMeta.OwnerId == comment.UserId)
{
Add(new OwnerTitleBadge(ownerMeta.OwnerTitle ?? string.Empty)
{
// add top space to align with username
Margin = new MarginPadding { Top = 1f },
});
}
if (comment.Pinned)
Add(new PinnedCommentNotice());
Add(new ParentUsername(comment));
Add(deletedLabel = new OsuSpriteText
{
Alpha = 0f,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Deleted
});
}
public void MarkDeleted()
{
deletedLabel.Show();
}
private partial class OwnerTitleBadge : CircularContainer
{
private readonly string title;
public OwnerTitleBadge(string title)
{
this.title = title;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
AutoSizeAxes = Axes.Both;
Masking = true;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Light1,
},
new OsuSpriteText
{
Text = title,
Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold),
Margin = new MarginPadding { Vertical = 2, Horizontal = 5 },
Colour = colourProvider.Background6,
},
};
}
}
private partial class PinnedCommentNotice : FillFlowContainer
{
public PinnedCommentNotice()
{
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(2, 0);
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Thumbtack,
Size = new Vector2(14),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Pinned,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
};
}
}
private partial class ParentUsername : FillFlowContainer, IHasTooltip
{
public LocalisableString TooltipText => getParentMessage();
private readonly Comment? parentComment;
public ParentUsername(Comment comment)
{
parentComment = comment.ParentComment;
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(3, 0);
Alpha = comment.ParentId == null ? 0 : 1;
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Reply,
Size = new Vector2(14),
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true),
Text = parentComment?.User?.Username ?? parentComment?.LegacyName!
}
};
}
private LocalisableString getParentMessage()
{
if (parentComment == null)
return string.Empty;
return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? CommentsStrings.Deleted : string.Empty;
}
}
}
}

View File

@ -301,7 +301,7 @@ namespace osu.Game.Overlays.Comments
void addNewComment(Comment comment)
{
var drawableComment = GetDrawableComment(comment);
var drawableComment = GetDrawableComment(comment, bundle.CommentableMeta);
if (comment.ParentId == null)
{
@ -333,7 +333,7 @@ namespace osu.Game.Overlays.Comments
if (CommentDictionary.ContainsKey(comment.Id))
continue;
topLevelComments.Add(GetDrawableComment(comment));
topLevelComments.Add(GetDrawableComment(comment, bundle.CommentableMeta));
}
if (topLevelComments.Any())
@ -351,12 +351,12 @@ namespace osu.Game.Overlays.Comments
}
}
public DrawableComment GetDrawableComment(Comment comment)
public DrawableComment GetDrawableComment(Comment comment, IReadOnlyList<CommentableMeta> meta)
{
if (CommentDictionary.TryGetValue(comment.Id, out var existing))
return existing;
return CommentDictionary[comment.Id] = new DrawableComment(comment)
return CommentDictionary[comment.Id] = new DrawableComment(comment, meta)
{
ShowDeleted = { BindTarget = ShowDeleted },
Sort = { BindTarget = Sort },

View File

@ -4,12 +4,10 @@
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Framework.Graphics.Sprites;
using osuTK;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users.Drawables;
using osu.Game.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Bindables;
using System.Linq;
using osu.Game.Graphics.Sprites;
@ -21,7 +19,6 @@ using osu.Framework.Extensions.IEnumerableExtensions;
using System.Collections.Specialized;
using System.Diagnostics;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Graphics.UserInterface;
@ -42,6 +39,7 @@ namespace osu.Game.Overlays.Comments
public Action<DrawableComment, int> RepliesRequested = null!;
public readonly Comment Comment;
public readonly IReadOnlyList<CommentableMeta> Meta;
public readonly BindableBool ShowDeleted = new BindableBool();
public readonly Bindable<CommentsSortCriteria> Sort = new Bindable<CommentsSortCriteria>();
@ -72,7 +70,7 @@ namespace osu.Game.Overlays.Comments
private LinkFlowContainer actionsContainer = null!;
private LoadingSpinner actionsLoading = null!;
private DeletedCommentsCounter deletedCommentsCounter = null!;
private OsuSpriteText deletedLabel = null!;
private CommentAuthorLine author = null!;
private GridContainer content = null!;
private VotePill votePill = null!;
private Container<CommentEditor> replyEditorContainer = null!;
@ -90,15 +88,15 @@ namespace osu.Game.Overlays.Comments
[Resolved]
private OnScreenDisplay? onScreenDisplay { get; set; }
public DrawableComment(Comment comment)
public DrawableComment(Comment comment, IReadOnlyList<CommentableMeta> meta)
{
Comment = comment;
Meta = meta;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, DrawableComment? parentComment)
{
LinkFlowContainer username;
FillFlowContainer info;
CommentMarkdownContainer message;
@ -174,27 +172,7 @@ namespace osu.Game.Overlays.Comments
},
Children = new Drawable[]
{
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new[]
{
username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold))
{
AutoSizeAxes = Axes.Both
},
Comment.Pinned ? new PinnedCommentNotice() : Empty(),
new ParentUsername(Comment),
deletedLabel = new OsuSpriteText
{
Alpha = 0f,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Deleted
}
}
},
author = new CommentAuthorLine(Comment, Meta),
message = new CommentMarkdownContainer
{
RelativeSizeAxes = Axes.X,
@ -218,7 +196,7 @@ namespace osu.Game.Overlays.Comments
{
new DrawableDate(Comment.CreatedAt, 12, false)
{
Colour = colourProvider.Foreground1
Colour = colourProvider.Foreground1,
}
}
},
@ -311,11 +289,6 @@ namespace osu.Game.Overlays.Comments
}
};
if (Comment.UserId.HasValue)
username.AddUserLink(Comment.User);
else
username.AddText(Comment.LegacyName!);
if (Comment.EditedAt.HasValue && Comment.EditedUser != null)
{
var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular);
@ -400,7 +373,7 @@ namespace osu.Game.Overlays.Comments
/// </summary>
private void makeDeleted()
{
deletedLabel.Show();
author.MarkDeleted();
content.FadeColour(OsuColour.Gray(0.5f));
votePill.Hide();
actionsContainer.Expire();
@ -547,70 +520,5 @@ namespace osu.Game.Overlays.Comments
Top = 10
};
}
private partial class PinnedCommentNotice : FillFlowContainer
{
public PinnedCommentNotice()
{
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(2, 0);
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Thumbtack,
Size = new Vector2(14),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Pinned,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
};
}
}
private partial class ParentUsername : FillFlowContainer, IHasTooltip
{
public LocalisableString TooltipText => getParentMessage();
private readonly Comment? parentComment;
public ParentUsername(Comment comment)
{
parentComment = comment.ParentComment;
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(3, 0);
Alpha = comment.ParentId == null ? 0 : 1;
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Reply,
Size = new Vector2(14),
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true),
Text = parentComment?.User?.Username ?? parentComment?.LegacyName!
}
};
}
private LocalisableString getParentMessage()
{
if (parentComment == null)
return string.Empty;
return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? CommentsStrings.Deleted : string.Empty;
}
}
}
}

View File

@ -60,7 +60,7 @@ namespace osu.Game.Overlays.Comments
foreach (var comment in cb.Comments)
comment.ParentComment = parentComment;
var drawables = cb.Comments.Select(commentsContainer.GetDrawableComment).ToArray();
var drawables = cb.Comments.Select(c => commentsContainer.GetDrawableComment(c, cb.CommentableMeta)).ToArray();
OnPost?.Invoke(drawables);
OnCancel!.Invoke();

View File

@ -8,16 +8,17 @@ using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring.Drawables;
using osu.Game.Utils;
using osuTK;
@ -213,42 +214,75 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks
private Drawable createDrawablePerformance()
{
if (!Score.PP.HasValue)
{
if (Score.Beatmap?.Status.GrantsPerformancePoints() == true)
return new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(16), Colour = colourProvider.Highlight1 };
var font = OsuFont.GetFont(weight: FontWeight.Bold);
return new OsuSpriteText
if (Score.PP.HasValue)
{
return new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new[]
{
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = font,
Text = $"{Score.PP:0}",
Colour = colourProvider.Highlight1
},
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = font.With(size: 12),
Text = "pp",
Colour = colourProvider.Light3
}
}
};
}
if (Score.Beatmap?.Status.GrantsPerformancePoints() != true)
{
if (Score.Beatmap?.Status == BeatmapOnlineStatus.Loved)
{
return new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Heart,
Size = new Vector2(font.Size),
TooltipText = UsersStrings.ShowExtraTopRanksNotRanked,
Colour = colourProvider.Highlight1
};
}
return new SpriteTextWithTooltip
{
Font = OsuFont.GetFont(weight: FontWeight.Bold),
Text = "-",
Font = OsuFont.GetFont(weight: FontWeight.Bold),
TooltipText = UsersStrings.ShowExtraTopRanksNotRanked,
Colour = colourProvider.Highlight1
};
}
return new FillFlowContainer
if (!Score.Ranked)
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new[]
return new SpriteTextWithTooltip
{
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.GetFont(weight: FontWeight.Bold),
Text = $"{Score.PP:0}",
Colour = colourProvider.Highlight1
},
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
Text = "pp",
Colour = colourProvider.Light3
}
}
Text = "-",
Font = OsuFont.GetFont(weight: FontWeight.Bold),
TooltipText = ScoresStrings.StatusNoPp,
Colour = colourProvider.Highlight1
};
}
return new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Sync,
Size = new Vector2(font.Size),
TooltipText = ScoresStrings.StatusProcessing,
Colour = colourProvider.Highlight1
};
}

View File

@ -78,6 +78,13 @@ namespace osu.Game.Overlays.Toolbar
}
});
Flow.Add(new TransientUserStatisticsUpdateDisplay
{
Alpha = 0
});
Flow.AutoSizeEasing = Easing.OutQuint;
Flow.AutoSizeDuration = 250;
apiState = api.State.GetBoundCopy();
apiState.BindValueChanged(onlineStateChanged, true);

View File

@ -0,0 +1,235 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Solo;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Toolbar
{
public partial class TransientUserStatisticsUpdateDisplay : CompositeDrawable
{
public Bindable<SoloStatisticsUpdate?> LatestUpdate { get; } = new Bindable<SoloStatisticsUpdate?>();
private Statistic<int> globalRank = null!;
private Statistic<decimal> pp = null!;
[BackgroundDependencyLoader]
private void load(SoloStatisticsWatcher? soloStatisticsWatcher)
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
Alpha = 0;
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 10 },
Spacing = new Vector2(10),
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
globalRank = new Statistic<int>(UsersStrings.ShowRankGlobalSimple, @"#", Comparer<int>.Create((before, after) => before - after)),
pp = new Statistic<decimal>(RankingsStrings.StatPerformance, string.Empty, Comparer<decimal>.Create((before, after) => Math.Sign(after - before))),
}
};
if (soloStatisticsWatcher != null)
((IBindable<SoloStatisticsUpdate?>)LatestUpdate).BindTo(soloStatisticsWatcher.LatestUpdate);
}
protected override void LoadComplete()
{
base.LoadComplete();
LatestUpdate.BindValueChanged(val =>
{
if (val.NewValue == null)
return;
var update = val.NewValue;
// null handling here is best effort because it is annoying.
globalRank.Alpha = update.After.GlobalRank == null ? 0 : 1;
pp.Alpha = update.After.PP == null ? 0 : 1;
if (globalRank.Alpha == 0 && pp.Alpha == 0)
return;
FinishTransforms(true);
this.FadeIn(500, Easing.OutQuint);
if (update.After.GlobalRank != null)
{
globalRank.Display(
update.Before.GlobalRank ?? update.After.GlobalRank.Value,
Math.Abs((update.After.GlobalRank.Value - update.Before.GlobalRank) ?? 0),
update.After.GlobalRank.Value);
}
if (update.After.PP != null)
pp.Display(update.Before.PP ?? update.After.PP.Value, Math.Abs((update.After.PP - update.Before.PP) ?? 0M), update.After.PP.Value);
this.Delay(5000).FadeOut(500, Easing.OutQuint);
});
}
private partial class Statistic<T> : CompositeDrawable
where T : struct, IEquatable<T>, IFormattable
{
private readonly LocalisableString title;
private readonly string mainValuePrefix;
private readonly IComparer<T> valueComparer;
private Counter<T> mainValue = null!;
private Counter<T> deltaValue = null!;
private OsuSpriteText titleText = null!;
private ScheduledDelegate? valueUpdateSchedule;
[Resolved]
private OsuColour colours { get; set; } = null!;
public Statistic(LocalisableString title, string mainValuePrefix, IComparer<T> valueComparer)
{
this.title = title;
this.mainValuePrefix = mainValuePrefix;
this.valueComparer = valueComparer;
}
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
InternalChild = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
mainValue = new Counter<T>
{
ValuePrefix = mainValuePrefix,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
},
new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Children = new Drawable[]
{
deltaValue = new Counter<T>
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Font = OsuFont.Default.With(size: 12, fixedWidth: true, weight: FontWeight.SemiBold),
AlwaysPresent = true,
},
titleText = new OsuSpriteText
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold),
Text = title,
AlwaysPresent = true,
}
}
}
}
};
}
public void Display(T before, T delta, T after)
{
valueUpdateSchedule?.Cancel();
valueUpdateSchedule = null;
int comparison = valueComparer.Compare(before, after);
if (comparison > 0)
{
deltaValue.Colour = colours.Lime1;
deltaValue.ValuePrefix = "+";
}
else if (comparison < 0)
{
deltaValue.Colour = colours.Red1;
deltaValue.ValuePrefix = "-";
}
else
{
deltaValue.Colour = Colour4.White;
deltaValue.ValuePrefix = string.Empty;
}
mainValue.SetCountWithoutRolling(before);
deltaValue.SetCountWithoutRolling(delta);
titleText.Alpha = 1;
deltaValue.Alpha = 0;
using (BeginDelayedSequence(1200))
{
titleText.FadeOut(250, Easing.OutQuad);
deltaValue.FadeIn(250, Easing.OutQuad);
using (BeginDelayedSequence(1250))
{
valueUpdateSchedule = Schedule(() =>
{
mainValue.Current.Value = after;
deltaValue.Current.SetDefault();
});
}
}
}
}
private partial class Counter<T> : RollingCounter<T>
where T : struct, IEquatable<T>, IFormattable
{
public FontUsage Font { get; init; } = OsuFont.Default.With(fixedWidth: true);
public string ValuePrefix
{
get => valuePrefix;
set
{
valuePrefix = value;
UpdateDisplay();
}
}
private string valuePrefix = string.Empty;
protected override LocalisableString FormatCount(T count) => LocalisableString.Format(@"{0}{1:N0}", ValuePrefix, count);
protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(t =>
{
t.Font = Font;
t.Spacing = new Vector2(-1.5f, 0);
});
protected override double RollingDuration => 1500;
}
}
}

View File

@ -1,27 +0,0 @@
// 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.
#nullable disable
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Scoring.Drawables
{
/// <summary>
/// A placeholder used in PP columns for scores with unprocessed PP value.
/// </summary>
public partial class UnprocessedPerformancePointsPlaceholder : SpriteIcon, IHasTooltip
{
public LocalisableString TooltipText => ScoresStrings.StatusProcessing;
public UnprocessedPerformancePointsPlaceholder()
{
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Icon = FontAwesome.Solid.ExclamationTriangle;
}
}
}

View File

@ -107,6 +107,12 @@ namespace osu.Game.Scoring
public double? PP { get; set; }
/// <summary>
/// Whether the performance points in this score is awarded to the player. This is used for online display purposes (see <see cref="SoloScoreInfo.Ranked"/>).
/// </summary>
[Ignored]
public bool Ranked { get; set; }
/// <summary>
/// The online ID of this score.
/// </summary>

View File

@ -17,6 +17,7 @@ using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@ -42,6 +43,10 @@ namespace osu.Game.Screens.Play
[Resolved]
private SessionStatics statics { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private SoloStatisticsWatcher soloStatisticsWatcher { get; set; }
private readonly object scoreSubmissionLock = new object();
private TaskCompletionSource<bool> scoreSubmissionSource;
@ -175,6 +180,7 @@ namespace osu.Game.Screens.Play
await submitScore(score).ConfigureAwait(false);
spectatorClient.EndPlaying(GameplayState);
soloStatisticsWatcher?.RegisterForStatisticsUpdateAfter(score.ScoreInfo);
}
[Resolved]

View File

@ -41,9 +41,6 @@ namespace osu.Game.Screens.Ranking
public override bool? AllowGlobalTrackControl => true;
// Temporary for now to stop dual transitions. Should respect the current toolbar mode, but there's no way to do so currently.
public override bool HideOverlaysOnEnter => true;
public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>();
[CanBeNull]

View File

@ -31,10 +31,7 @@ namespace osu.Game.Screens.Ranking
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private SoloStatisticsWatcher soloStatisticsWatcher { get; set; } = null!;
private IDisposable? statisticsSubscription;
private IBindable<SoloStatisticsUpdate?> latestUpdate = null!;
private readonly Bindable<SoloStatisticsUpdate?> statisticsUpdate = new Bindable<SoloStatisticsUpdate?>();
public SoloResultsScreen(ScoreInfo score, bool allowRetry)
@ -42,14 +39,20 @@ namespace osu.Game.Screens.Ranking
{
}
protected override void LoadComplete()
[BackgroundDependencyLoader]
private void load(SoloStatisticsWatcher? soloStatisticsWatcher)
{
base.LoadComplete();
if (ShowUserStatistics && soloStatisticsWatcher != null)
{
Debug.Assert(Score != null);
Debug.Assert(Score != null);
if (ShowUserStatistics)
statisticsSubscription = soloStatisticsWatcher.RegisterForStatisticsUpdateAfter(Score, update => statisticsUpdate.Value = update);
latestUpdate = soloStatisticsWatcher.LatestUpdate.GetBoundCopy();
latestUpdate.BindValueChanged(update =>
{
if (update.NewValue?.Score.MatchesOnlineID(Score) == true)
statisticsUpdate.Value = update.NewValue;
});
}
}
protected override StatisticsPanel CreateStatisticsPanel()
@ -84,7 +87,6 @@ namespace osu.Game.Screens.Ranking
base.Dispose(isDisposing);
getScoreRequest?.Cancel();
statisticsSubscription?.Dispose();
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select.Filter;
@ -64,6 +65,7 @@ namespace osu.Game.Screens.Select.Carousel
match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(BeatmapInfo.Difficulty.CircleSize);
match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.Difficulty.OverallDifficulty);
match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length);
match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(BeatmapInfo.LastPlayed ?? DateTimeOffset.MinValue);
match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM);
match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor);

View File

@ -91,19 +91,19 @@ namespace osu.Game.Screens.Select.Carousel
break;
case SortMode.LastPlayed:
comparison = -compareUsingAggregateMax(otherSet, b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds());
comparison = -compareUsingAggregateMax(otherSet, static b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds());
break;
case SortMode.BPM:
comparison = compareUsingAggregateMax(otherSet, b => b.BPM);
comparison = compareUsingAggregateMax(otherSet, static b => b.BPM);
break;
case SortMode.Length:
comparison = compareUsingAggregateMax(otherSet, b => b.Length);
comparison = compareUsingAggregateMax(otherSet, static b => b.Length);
break;
case SortMode.Difficulty:
comparison = compareUsingAggregateMax(otherSet, b => b.StarRating);
comparison = compareUsingAggregateMax(otherSet, static b => b.StarRating);
break;
case SortMode.DateSubmitted:
@ -127,12 +127,40 @@ namespace osu.Game.Screens.Select.Carousel
/// <summary>
/// All beatmaps which are not filtered and valid for display.
/// </summary>
protected IEnumerable<BeatmapInfo> ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value || b.State.Value == CarouselItemState.Selected).Select(b => b.BeatmapInfo);
protected IEnumerable<BeatmapInfo> ValidBeatmaps
{
get
{
foreach (var item in Items) // iterating over Items directly to not allocate 2 enumerators
{
if (item is CarouselBeatmap b && (!b.Filtered.Value || b.State.Value == CarouselItemState.Selected))
yield return b.BeatmapInfo;
}
}
}
/// <summary>
/// Whether there are available beatmaps which are not filtered and valid for display.
/// Cheaper alternative to <see cref="ValidBeatmaps"/>.Any()
/// </summary>
public bool HasValidBeatmaps
{
get
{
foreach (var item in Items) // iterating over Items directly to not allocate 2 enumerators
{
if (item is CarouselBeatmap b && (!b.Filtered.Value || b.State.Value == CarouselItemState.Selected))
return true;
}
return false;
}
}
private int compareUsingAggregateMax(CarouselBeatmapSet other, Func<BeatmapInfo, double> func)
{
bool ourBeatmaps = ValidBeatmaps.Any();
bool otherBeatmaps = other.ValidBeatmaps.Any();
bool ourBeatmaps = HasValidBeatmaps;
bool otherBeatmaps = other.HasValidBeatmaps;
if (!ourBeatmaps && !otherBeatmaps) return 0;
if (!ourBeatmaps) return -1;

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Extensions.ListExtensions;
using osu.Framework.Lists;
namespace osu.Game.Screens.Select.Carousel
{
@ -12,7 +14,7 @@ namespace osu.Game.Screens.Select.Carousel
{
public override DrawableCarouselItem? CreateDrawableRepresentation() => null;
public IReadOnlyList<CarouselItem> Items => items;
public SlimReadOnlyListWrapper<CarouselItem> Items => items.AsSlimReadOnly();
public int TotalItemsNotFiltered { get; private set; }

View File

@ -35,6 +35,7 @@ namespace osu.Game.Screens.Select
public OptionalRange<double> BPM;
public OptionalRange<int> BeatDivisor;
public OptionalRange<BeatmapOnlineStatus> OnlineStatus;
public OptionalRange<DateTimeOffset> LastPlayed;
public OptionalTextFilter Creator;
public OptionalTextFilter Artist;
public OptionalTextFilter Title;

View File

@ -61,6 +61,10 @@ namespace osu.Game.Screens.Select
case "length":
return tryUpdateLengthRange(criteria, op, value);
case "played":
case "lastplayed":
return tryUpdateDateAgoRange(ref criteria.LastPlayed, op, value);
case "divisor":
return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt);
@ -376,5 +380,107 @@ namespace osu.Game.Screens.Select
return tryUpdateCriteriaRange(ref criteria.Length, op, totalLength, minScale / 2.0);
}
/// <summary>
/// This function is intended for parsing "days / months / years ago" type filters.
/// </summary>
private static bool tryUpdateDateAgoRange(ref FilterCriteria.OptionalRange<DateTimeOffset> dateRange, Operator op, string val)
{
switch (op)
{
case Operator.Equal:
// an equality filter is difficult to define for support here.
// if "3 months 2 days ago" means a single concrete time instant, such a filter is basically useless.
// if it means a range of 24 hours, then that is annoying to write and also comes with its own implications
// (does it mean "time instant 3 months 2 days ago, within 12 hours of tolerance either direction"?
// does it mean "the full calendar day, from midnight to midnight, 3 months 2 days ago"?)
// as such, for simplicity, just refuse to support this.
return false;
// for the remaining operators, since the value provided to this function is an "ago" type value
// (as in, referring to some amount of time back),
// we'll want to flip the operator, such that `>5d` means "more than five days ago", as in "*before* five days ago",
// as intended by the user.
case Operator.Less:
op = Operator.Greater;
break;
case Operator.LessOrEqual:
op = Operator.GreaterOrEqual;
break;
case Operator.Greater:
op = Operator.Less;
break;
case Operator.GreaterOrEqual:
op = Operator.LessOrEqual;
break;
}
GroupCollection? match = null;
match ??= tryMatchRegex(val, @"^((?<years>\d+)y)?((?<months>\d+)M)?((?<days>\d+(\.\d+)?)d)?((?<hours>\d+(\.\d+)?)h)?((?<minutes>\d+(\.\d+)?)m)?((?<seconds>\d+(\.\d+)?)s)?$");
match ??= tryMatchRegex(val, @"^(?<days>\d+(\.\d+)?)$");
if (match == null)
return false;
DateTimeOffset? dateTimeOffset = null;
DateTimeOffset now = DateTimeOffset.Now;
try
{
List<string> keys = new List<string> { @"seconds", @"minutes", @"hours", @"days", @"months", @"years" };
foreach (string key in keys)
{
if (!match.TryGetValue(key, out var group) || !group.Success)
continue;
if (group.Success)
{
if (!tryParseDoubleWithPoint(group.Value, out double length))
return false;
switch (key)
{
case @"seconds":
dateTimeOffset = (dateTimeOffset ?? now).AddSeconds(-length);
break;
case @"minutes":
dateTimeOffset = (dateTimeOffset ?? now).AddMinutes(-length);
break;
case @"hours":
dateTimeOffset = (dateTimeOffset ?? now).AddHours(-length);
break;
case @"days":
dateTimeOffset = (dateTimeOffset ?? now).AddDays(-length);
break;
case @"months":
dateTimeOffset = (dateTimeOffset ?? now).AddMonths(-(int)length);
break;
case @"years":
dateTimeOffset = (dateTimeOffset ?? now).AddYears(-(int)length);
break;
}
}
}
}
catch (ArgumentOutOfRangeException)
{
dateTimeOffset = DateTimeOffset.MinValue.AddMilliseconds(1);
}
if (!dateTimeOffset.HasValue)
return false;
return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset.Value);
}
}
}

View File

@ -37,7 +37,7 @@
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.205.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.129.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.207.0" />
<PackageReference Include="Sentry" Version="3.41.3" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
<PackageReference Include="SharpCompress" Version="0.36.0" />