1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-25 17:49:57 +08:00

Merge branch 'master' into song-select-v2-wedges-metadata

This commit is contained in:
Dean Herbert
2025-04-22 17:34:57 +09:00
committed by GitHub
Unverified
65 changed files with 2544 additions and 942 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.321.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.419.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
@@ -0,0 +1,470 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Mania.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene
{
protected override Ruleset CreateRuleset() => new ManiaRuleset();
protected override string? ExportLocation => null;
private static readonly object[][] score_v2_test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// Note that mania hitwindows are heavily idiosyncratic,
// and if you *think* a number here is wrong, probably double check.
// Known issues / complexities:
// - There is a disparate set of hitwindow ranges for: score V1 non-converts, score V1 converts, and score V2 (regardless of convert)
// - It is NEVER POSSIBLE to get a MEH result when late; exceeding the OK hit windows will result in a MISS.
// Additionally, the OK hit window when late is EXCLUSIVE / OPEN rather than INCLUSIVE / CLOSED.
// Relevant stable source: https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameplayElements/HitObjectManagerMania.cs#L737-L751
// - There is also a seemingly mania-specific issue wherein key inputs registered before time instant 0 get truncated to time 0,
// which is why the beatmaps used below make sure not to cross that boundary (the note starts at t=300ms).
// This is not an issue in osu! or taiko.
// The source of this behaviour has not been investigated in detail.
// OD = 5 test cases.
// PERFECT hit window is [ -19ms, 19ms]
// GREAT hit window is [ -49ms, 49ms]
// GOOD hit window is [ -82ms, 82ms]
// OK hit window is [-112ms, 112ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-136ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -18d, HitResult.Perfect },
new object[] { 5f, -19d, HitResult.Perfect },
new object[] { 5f, -20d, HitResult.Great },
new object[] { 5f, -21d, HitResult.Great },
new object[] { 5f, -48d, HitResult.Great },
new object[] { 5f, -49d, HitResult.Great },
new object[] { 5f, -50d, HitResult.Good },
new object[] { 5f, -51d, HitResult.Good },
new object[] { 5f, -81d, HitResult.Good },
new object[] { 5f, -82d, HitResult.Good },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -84d, HitResult.Ok },
new object[] { 5f, -111d, HitResult.Ok },
new object[] { 5f, -112d, HitResult.Ok },
new object[] { 5f, -113d, HitResult.Meh },
new object[] { 5f, -114d, HitResult.Meh },
new object[] { 5f, -135d, HitResult.Meh },
new object[] { 5f, -136d, HitResult.Meh },
new object[] { 5f, -137d, HitResult.Miss },
new object[] { 5f, -138d, HitResult.Miss },
new object[] { 5f, 111d, HitResult.Ok },
new object[] { 5f, 112d, HitResult.Miss },
new object[] { 5f, 113d, HitResult.Miss },
new object[] { 5f, 114d, HitResult.Miss },
new object[] { 5f, 135d, HitResult.Miss },
new object[] { 5f, 136d, HitResult.Miss },
new object[] { 5f, 137d, HitResult.Miss },
new object[] { 5f, 138d, HitResult.Miss },
// OD = 9.3 test cases.
// PERFECT hit window is [ -14ms, 14ms]
// GREAT hit window is [ -36ms, 36ms]
// GOOD hit window is [ -69ms, 69ms]
// OK hit window is [ -99ms, 99ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-123ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 9.3f, 13d, HitResult.Perfect },
new object[] { 9.3f, 14d, HitResult.Perfect },
new object[] { 9.3f, 15d, HitResult.Great },
new object[] { 9.3f, 16d, HitResult.Great },
new object[] { 9.3f, 35d, HitResult.Great },
new object[] { 9.3f, 36d, HitResult.Great },
new object[] { 9.3f, 37d, HitResult.Good },
new object[] { 9.3f, 38d, HitResult.Good },
new object[] { 9.3f, 68d, HitResult.Good },
new object[] { 9.3f, 69d, HitResult.Good },
new object[] { 9.3f, 70d, HitResult.Ok },
new object[] { 9.3f, 71d, HitResult.Ok },
new object[] { 9.3f, 98d, HitResult.Ok },
new object[] { 9.3f, 99d, HitResult.Miss },
new object[] { 9.3f, 100d, HitResult.Miss },
new object[] { 9.3f, 101d, HitResult.Miss },
new object[] { 9.3f, 122d, HitResult.Miss },
new object[] { 9.3f, 123d, HitResult.Miss },
new object[] { 9.3f, 124d, HitResult.Miss },
new object[] { 9.3f, 125d, HitResult.Miss },
new object[] { 9.3f, -98d, HitResult.Ok },
new object[] { 9.3f, -99d, HitResult.Ok },
new object[] { 9.3f, -100d, HitResult.Meh },
new object[] { 9.3f, -101d, HitResult.Meh },
new object[] { 9.3f, -122d, HitResult.Meh },
new object[] { 9.3f, -123d, HitResult.Meh },
new object[] { 9.3f, -124d, HitResult.Miss },
new object[] { 9.3f, -125d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_test_cases =
{
// OD = 5 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -49ms, 49ms]
// GOOD hit window is [ -82ms, 82ms]
// OK hit window is [-112ms, 112ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-136ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -15d, HitResult.Perfect },
new object[] { 5f, -16d, HitResult.Perfect },
new object[] { 5f, -17d, HitResult.Great },
new object[] { 5f, -18d, HitResult.Great },
new object[] { 5f, -48d, HitResult.Great },
new object[] { 5f, -49d, HitResult.Great },
new object[] { 5f, -50d, HitResult.Good },
new object[] { 5f, -51d, HitResult.Good },
new object[] { 5f, -81d, HitResult.Good },
new object[] { 5f, -82d, HitResult.Good },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -84d, HitResult.Ok },
new object[] { 5f, -111d, HitResult.Ok },
new object[] { 5f, -112d, HitResult.Ok },
new object[] { 5f, -113d, HitResult.Meh },
new object[] { 5f, -114d, HitResult.Meh },
new object[] { 5f, -135d, HitResult.Meh },
new object[] { 5f, -136d, HitResult.Meh },
new object[] { 5f, -137d, HitResult.Miss },
new object[] { 5f, -138d, HitResult.Miss },
new object[] { 5f, 111d, HitResult.Ok },
new object[] { 5f, 112d, HitResult.Miss },
new object[] { 5f, 113d, HitResult.Miss },
new object[] { 5f, 114d, HitResult.Miss },
new object[] { 5f, 135d, HitResult.Miss },
new object[] { 5f, 136d, HitResult.Miss },
new object[] { 5f, 137d, HitResult.Miss },
new object[] { 5f, 138d, HitResult.Miss },
// OD = 9.3 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -36ms, 36ms]
// GOOD hit window is [ -69ms, 69ms]
// OK hit window is [ -99ms, 99ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-123ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 9.3f, 15d, HitResult.Perfect },
new object[] { 9.3f, 16d, HitResult.Perfect },
new object[] { 9.3f, 17d, HitResult.Great },
new object[] { 9.3f, 18d, HitResult.Great },
new object[] { 9.3f, 35d, HitResult.Great },
new object[] { 9.3f, 36d, HitResult.Great },
new object[] { 9.3f, 37d, HitResult.Good },
new object[] { 9.3f, 38d, HitResult.Good },
new object[] { 9.3f, 68d, HitResult.Good },
new object[] { 9.3f, 69d, HitResult.Good },
new object[] { 9.3f, 70d, HitResult.Ok },
new object[] { 9.3f, 71d, HitResult.Ok },
new object[] { 9.3f, 98d, HitResult.Ok },
new object[] { 9.3f, 99d, HitResult.Miss },
new object[] { 9.3f, 100d, HitResult.Miss },
new object[] { 9.3f, 101d, HitResult.Miss },
new object[] { 9.3f, 122d, HitResult.Miss },
new object[] { 9.3f, 123d, HitResult.Miss },
new object[] { 9.3f, 124d, HitResult.Miss },
new object[] { 9.3f, 125d, HitResult.Miss },
new object[] { 9.3f, -98d, HitResult.Ok },
new object[] { 9.3f, -99d, HitResult.Ok },
new object[] { 9.3f, -100d, HitResult.Meh },
new object[] { 9.3f, -101d, HitResult.Meh },
new object[] { 9.3f, -122d, HitResult.Meh },
new object[] { 9.3f, -123d, HitResult.Meh },
new object[] { 9.3f, -124d, HitResult.Miss },
new object[] { 9.3f, -125d, HitResult.Miss },
// OD = 3.1 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -54ms, 54ms]
// GOOD hit window is [ -87ms, 87ms]
// OK hit window is [-117ms, 117ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-141ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 3.1f, 15d, HitResult.Perfect },
new object[] { 3.1f, 16d, HitResult.Perfect },
new object[] { 3.1f, 17d, HitResult.Great },
new object[] { 3.1f, 18d, HitResult.Great },
new object[] { 3.1f, 53d, HitResult.Great },
new object[] { 3.1f, 54d, HitResult.Great },
new object[] { 3.1f, 55d, HitResult.Good },
new object[] { 3.1f, 56d, HitResult.Good },
new object[] { 3.1f, 86d, HitResult.Good },
new object[] { 3.1f, 87d, HitResult.Good },
new object[] { 3.1f, 88d, HitResult.Ok },
new object[] { 3.1f, 89d, HitResult.Ok },
new object[] { 3.1f, 116d, HitResult.Ok },
new object[] { 3.1f, 117d, HitResult.Miss },
new object[] { 3.1f, 118d, HitResult.Miss },
new object[] { 3.1f, 119d, HitResult.Miss },
new object[] { 3.1f, 140d, HitResult.Miss },
new object[] { 3.1f, 141d, HitResult.Miss },
new object[] { 3.1f, 142d, HitResult.Miss },
new object[] { 3.1f, 143d, HitResult.Miss },
new object[] { 3.1f, -116d, HitResult.Ok },
new object[] { 3.1f, -117d, HitResult.Ok },
new object[] { 3.1f, -118d, HitResult.Meh },
new object[] { 3.1f, -119d, HitResult.Meh },
new object[] { 3.1f, -140d, HitResult.Meh },
new object[] { 3.1f, -141d, HitResult.Meh },
new object[] { 3.1f, -142d, HitResult.Miss },
new object[] { 3.1f, -143d, HitResult.Miss },
};
private static readonly object[][] score_v1_convert_test_cases =
{
// OD = 5 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -34ms, 34ms]
// GOOD hit window is [ -67ms, 67ms]
// OK hit window is [ -97ms, 97ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-121ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -15d, HitResult.Perfect },
new object[] { 5f, -16d, HitResult.Perfect },
new object[] { 5f, -17d, HitResult.Great },
new object[] { 5f, -18d, HitResult.Great },
new object[] { 5f, -33d, HitResult.Great },
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Good },
new object[] { 5f, -36d, HitResult.Good },
new object[] { 5f, -66d, HitResult.Good },
new object[] { 5f, -67d, HitResult.Good },
new object[] { 5f, -68d, HitResult.Ok },
new object[] { 5f, -69d, HitResult.Ok },
new object[] { 5f, -96d, HitResult.Ok },
new object[] { 5f, -97d, HitResult.Ok },
new object[] { 5f, -98d, HitResult.Meh },
new object[] { 5f, -99d, HitResult.Meh },
new object[] { 5f, -120d, HitResult.Meh },
new object[] { 5f, -121d, HitResult.Meh },
new object[] { 5f, -122d, HitResult.Miss },
new object[] { 5f, -123d, HitResult.Miss },
new object[] { 5f, 96d, HitResult.Ok },
new object[] { 5f, 97d, HitResult.Miss },
new object[] { 5f, 98d, HitResult.Miss },
new object[] { 5f, 99d, HitResult.Miss },
new object[] { 5f, 120d, HitResult.Miss },
new object[] { 5f, 121d, HitResult.Miss },
new object[] { 5f, 122d, HitResult.Miss },
new object[] { 5f, 123d, HitResult.Miss },
// OD = 3.1 test cases.
// PERFECT hit window is [ -16ms, 16ms]
// GREAT hit window is [ -47ms, 47ms]
// GOOD hit window is [ -77ms, 77ms]
// OK hit window is [ -97ms, 97ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-121ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 3.1f, 15d, HitResult.Perfect },
new object[] { 3.1f, 16d, HitResult.Perfect },
new object[] { 3.1f, 17d, HitResult.Great },
new object[] { 3.1f, 18d, HitResult.Great },
new object[] { 3.1f, 46d, HitResult.Great },
new object[] { 3.1f, 47d, HitResult.Great },
new object[] { 3.1f, 48d, HitResult.Good },
new object[] { 3.1f, 49d, HitResult.Good },
new object[] { 3.1f, 76d, HitResult.Good },
new object[] { 3.1f, 77d, HitResult.Good },
new object[] { 3.1f, 78d, HitResult.Ok },
new object[] { 3.1f, 79d, HitResult.Ok },
new object[] { 3.1f, 96d, HitResult.Ok },
new object[] { 3.1f, 97d, HitResult.Miss },
new object[] { 3.1f, 98d, HitResult.Miss },
new object[] { 3.1f, 99d, HitResult.Miss },
new object[] { 3.1f, 120d, HitResult.Miss },
new object[] { 3.1f, 121d, HitResult.Miss },
new object[] { 3.1f, 122d, HitResult.Miss },
new object[] { 3.1f, 123d, HitResult.Miss },
new object[] { 3.1f, -96d, HitResult.Ok },
new object[] { 3.1f, -97d, HitResult.Ok },
new object[] { 3.1f, -98d, HitResult.Meh },
new object[] { 3.1f, -99d, HitResult.Meh },
new object[] { 3.1f, -120d, HitResult.Meh },
new object[] { 3.1f, -121d, HitResult.Meh },
new object[] { 3.1f, -122d, HitResult.Miss },
new object[] { 3.1f, -123d, HitResult.Miss },
};
[TestCaseSource(nameof(score_v2_test_cases))]
public void TestHitWindowTreatmentWithScoreV2(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double note_time = 300;
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new ManiaBeatmap(new StageDefinition(1))
{
HitObjects =
{
new Note
{
StartTime = note_time,
Column = 0,
}
},
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = overallDifficulty,
CircleSize = 1,
},
BeatmapInfo =
{
Ruleset = new ManiaRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ModScoreV2()]
}
};
RunTest($@"SV2 single note @ OD{overallDifficulty}", beatmap, $@"SV2 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_test_cases))]
public void TestHitWindowTreatmentWithScoreV1NonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double note_time = 300;
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new ManiaBeatmap(new StageDefinition(1))
{
HitObjects =
{
new Note
{
StartTime = note_time,
Column = 0,
}
},
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = overallDifficulty,
CircleSize = 1,
},
BeatmapInfo =
{
Ruleset = new ManiaRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
}
};
RunTest($@"SV1 single note @ OD{overallDifficulty}", beatmap, $@"SV1 {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_convert_test_cases))]
public void TestHitWindowTreatmentWithScoreV1Convert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double note_time = 300;
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new Beatmap
{
HitObjects =
{
new FakeCircle
{
StartTime = note_time,
}
},
Difficulty = new BeatmapDifficulty
{
OverallDifficulty = overallDifficulty,
},
BeatmapInfo =
{
Ruleset = new RulesetInfo { OnlineID = 0 }
},
ControlPointInfo = cpi,
};
var replay = new Replay
{
Frames =
{
new ManiaReplayFrame(0),
new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1),
new ManiaReplayFrame(note_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
Mods = [new ManiaModKey1()],
}
};
RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private class FakeCircle : HitObject, IHasPosition
{
public float X
{
get => Position.X;
set => Position = new Vector2(value, Position.Y);
}
public float Y
{
get => Position.Y;
set => Position = new Vector2(Position.X, value);
}
public Vector2 Position { get; set; }
}
}
}
@@ -17,6 +17,10 @@ namespace osu.Game.Rulesets.Mania.Tests
{
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// OD = 5 test cases.
// PERFECT hit window is [ -19.4ms, 19.4ms]
// GREAT hit window is [ -49.0ms, 49.0ms]
@@ -0,0 +1,118 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene
{
protected override Ruleset CreateRuleset() => new OsuRuleset();
protected override string? ExportLocation => null;
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// Additionally, note that offsets provided in double will be rounded to the nearest integer.
// OD = 5 test cases.
// GREAT hit window is ( -50ms, 50ms)
// OK hit window is (-100ms, 100ms)
// MEH hit window is (-150ms, 150ms)
new object[] { 5f, 48d, HitResult.Great },
new object[] { 5f, 49d, HitResult.Great },
new object[] { 5f, 50d, HitResult.Ok },
new object[] { 5f, 51d, HitResult.Ok },
new object[] { 5f, 98d, HitResult.Ok },
new object[] { 5f, 99d, HitResult.Ok },
new object[] { 5f, 100d, HitResult.Meh },
new object[] { 5f, 101d, HitResult.Meh },
new object[] { 5f, 148d, HitResult.Meh },
new object[] { 5f, 149d, HitResult.Meh },
new object[] { 5f, 150d, HitResult.Miss },
new object[] { 5f, 151d, HitResult.Miss },
// OD = 5.7 test cases.
// GREAT hit window is ( -45ms, 45ms)
// OK hit window is ( -94ms, 94ms)
// MEH hit window is (-143ms, 143ms)
new object[] { 5.7f, 43d, HitResult.Great },
new object[] { 5.7f, 44d, HitResult.Great },
new object[] { 5.7f, 45d, HitResult.Ok },
new object[] { 5.7f, 46d, HitResult.Ok },
new object[] { 5.7f, 92d, HitResult.Ok },
new object[] { 5.7f, 93d, HitResult.Ok },
new object[] { 5.7f, 94d, HitResult.Meh },
new object[] { 5.7f, 95d, HitResult.Meh },
new object[] { 5.7f, 141d, HitResult.Meh },
new object[] { 5.7f, 142d, HitResult.Meh },
new object[] { 5.7f, 143d, HitResult.Miss },
new object[] { 5.7f, 144d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double hit_circle_time = 100;
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new OsuBeatmap
{
HitObjects =
{
new HitCircle
{
StartTime = hit_circle_time,
Position = OsuPlayfield.BASE_SIZE / 2
}
},
Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty },
BeatmapInfo =
{
Ruleset = new OsuRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
var replay = new Replay
{
Frames =
{
// required for correct playback in stable
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, new Vector2(256, -500)),
new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2),
new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton),
new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
}
};
RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
}
}
@@ -18,6 +18,10 @@ namespace osu.Game.Rulesets.Osu.Tests
{
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// OD = 5 test cases.
// GREAT hit window is [ -50ms, 50ms]
// OK hit window is [-100ms, 100ms]
@@ -176,10 +176,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
// More or less matches stable (see https://github.com/peppy/osu-stable-reference/blob/bb57924c1552adbed11ee3d96cdcde47cf96f2b6/osu!/GameplayElements/HitObjects/Osu/HitCircleOsu.cs#L336-L338)
AccentColour.Value = Color4.White;
Alpha = Interpolation.ValueAt(Time.Current, 1f, 0f, HitStateUpdateTime, HitStateUpdateTime + 700);
Arrow.Alpha = 0;
}
Arrow.Alpha = hit ? 0 : 1;
LifetimeEnd = HitStateUpdateTime + 700;
}
@@ -0,0 +1,102 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Taiko.Tests
{
[Ignore("These tests are expected to fail until an acceptable solution for various replay playback issues concerning rounding of replay frame times & hit windows is found.")]
public partial class TestSceneLegacyReplayPlayback : LegacyReplayPlaybackTestScene
{
protected override string? ExportLocation => null;
protected override Ruleset CreateRuleset() => new TaikoRuleset();
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// OD = 5 test cases.
// GREAT hit window is (-35ms, 35ms)
// OK hit window is (-80ms, 80ms)
new object[] { 5f, -33d, HitResult.Great },
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Ok },
new object[] { 5f, -36d, HitResult.Ok },
new object[] { 5f, -78d, HitResult.Ok },
new object[] { 5f, -79d, HitResult.Ok },
new object[] { 5f, -80d, HitResult.Miss },
new object[] { 5f, -81d, HitResult.Miss },
// OD = 7.8 test cases.
// GREAT hit window is (-26ms, 26ms)
// OK hit window is (-63ms, 63ms)
new object[] { 7.8f, -24d, HitResult.Great },
new object[] { 7.8f, -25d, HitResult.Great },
new object[] { 7.8f, -26d, HitResult.Ok },
new object[] { 7.8f, -27d, HitResult.Ok },
new object[] { 7.8f, -61d, HitResult.Ok },
new object[] { 7.8f, -62d, HitResult.Ok },
new object[] { 7.8f, -63d, HitResult.Miss },
new object[] { 7.8f, -64d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
const double hit_time = 100;
var cpi = new ControlPointInfo();
cpi.Add(0, new TimingControlPoint { BeatLength = 1000 });
var beatmap = new TaikoBeatmap
{
HitObjects =
{
new Hit
{
StartTime = hit_time,
Type = HitType.Centre,
}
},
Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty },
BeatmapInfo =
{
Ruleset = new TaikoRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
};
var replay = new Replay
{
Frames =
{
new TaikoReplayFrame(0),
new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre),
new TaikoReplayFrame(hit_time + hitOffset + 20),
}
};
var score = new Score
{
Replay = replay,
ScoreInfo = new ScoreInfo
{
Ruleset = CreateRuleset().RulesetInfo,
}
};
RunTest($@"single hit @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
}
}
@@ -17,6 +17,10 @@ namespace osu.Game.Rulesets.Taiko.Tests
{
private static readonly object[][] test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
// while round brackets `()` represent *open* or *exclusive* bounds.
// OD = 5 test cases.
// GREAT hit window is [-35ms, 35ms]
// OK hit window is [-80ms, 80ms]
@@ -0,0 +1,54 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Extensions;
namespace osu.Game.Tests.Extensions
{
[TestFixture]
public class NumberFormattingExtensionsTest
{
[TestCase(-1, false, 0, ExpectedResult = "-1")]
[TestCase(0, false, 0, ExpectedResult = "0")]
[TestCase(1, false, 0, ExpectedResult = "1")]
[TestCase(500, false, 10, ExpectedResult = "500")]
[TestCase(-1, true, 0, ExpectedResult = "-1%")]
[TestCase(0, true, 0, ExpectedResult = "0%")]
[TestCase(1, true, 0, ExpectedResult = "1%")]
[TestCase(50, true, 0, ExpectedResult = "50%")]
public string TestInteger(int input, bool percent, int decimalDigits)
{
return input.ToStandardFormattedString(decimalDigits, percent);
}
[TestCase(-1, false, 0, ExpectedResult = "-1")]
[TestCase(-1e-6, false, 0, ExpectedResult = "0")]
[TestCase(-1e-6, false, 6, ExpectedResult = "-0.000001")]
[TestCase(0, false, 10, ExpectedResult = "0")]
[TestCase(0, false, 0, ExpectedResult = "0")]
[TestCase(double.NegativeZero, false, 0, ExpectedResult = "0")]
[TestCase(1e-6, false, 0, ExpectedResult = "0")]
[TestCase(1e-6, false, 6, ExpectedResult = "0.000001")]
[TestCase(1, false, 0, ExpectedResult = "1")]
[TestCase(1.528, false, 2, ExpectedResult = "1.53")]
[TestCase(500, false, 10, ExpectedResult = "500")]
[TestCase(-0.1, true, 0, ExpectedResult = "-10%")]
[TestCase(0, true, 0, ExpectedResult = "0%")]
[TestCase(0.4, true, 0, ExpectedResult = "40%")]
[TestCase(0.48333, true, 2, ExpectedResult = "48%")]
[TestCase(0.48333, true, 4, ExpectedResult = "48.33%")]
[TestCase(1, true, 0, ExpectedResult = "100%")]
public string TestDouble(double input, bool percent, int decimalDigits)
{
return input.ToStandardFormattedString(decimalDigits, percent);
}
[Test]
[SetCulture("fr-FR")]
public void TestCultureInsensitivity()
{
Assert.That(0.4.ToStandardFormattedString(maxDecimalDigits: 2, asPercentage: true), Is.EqualTo("40%"));
}
}
}
@@ -1,12 +1,12 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Extensions.PolygonExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -16,6 +16,7 @@ using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
using osuTK;
namespace osu.Game.Tests.Visual.Gameplay
@@ -23,7 +24,10 @@ namespace osu.Game.Tests.Visual.Gameplay
[TestFixture]
public partial class TestSceneGameplayLeaderboard : OsuTestScene
{
private TestGameplayLeaderboard leaderboard;
private TestDrawableGameplayLeaderboard leaderboard = null!;
[Cached(typeof(IGameplayLeaderboardProvider))]
private TestGameplayLeaderboardProvider leaderboardProvider = new TestGameplayLeaderboardProvider();
private readonly BindableLong playerScore = new BindableLong();
@@ -31,7 +35,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("toggle expanded", () =>
{
if (leaderboard != null)
if (leaderboard.IsNotNull())
leaderboard.Expanded.Value = !leaderboard.Expanded.Value;
});
@@ -57,10 +61,10 @@ namespace osu.Game.Tests.Visual.Gameplay
// has caused layout to not work in the past.
AddUntilStep("wait for fill flow layout",
() => leaderboard.ChildrenOfType<FillFlowContainer<GameplayLeaderboardScore>>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad));
() => leaderboard.ChildrenOfType<FillFlowContainer<DrawableGameplayLeaderboardScore>>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad));
AddUntilStep("wait for some scores not masked away",
() => leaderboard.ChildrenOfType<GameplayLeaderboardScore>().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre)));
() => leaderboard.ChildrenOfType<DrawableGameplayLeaderboardScore>().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre)));
AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad));
@@ -139,7 +143,7 @@ namespace osu.Game.Tests.Visual.Gameplay
checkHeight(8);
void checkHeight(int panelCount)
=> AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (GameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount);
=> AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (DrawableGameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount);
}
[Test]
@@ -179,6 +183,30 @@ namespace osu.Game.Tests.Visual.Gameplay
() => Does.Contain("#FF549A"));
}
[Test]
public void TestTrackedScorePosition([Values] bool partial)
{
createLeaderboard(partial);
AddStep("add many scores in one go", () =>
{
for (int i = 0; i < 49; i++)
createRandomScore(new APIUser { Username = $"Player {i + 1}" });
// Add player at end to force an animation down the whole list.
playerScore.Value = 0;
createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true);
});
if (partial)
AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null);
else
AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50));
AddStep("move tracked player to top", () => leaderboard.TrackedScore!.TotalScore.Value = 8_000_000);
AddUntilStep("all players have non-null position", () => leaderboard.AllScores.Select(s => s.ScorePosition), () => Does.Not.Contain(null));
}
private void addLocalPlayer()
{
AddStep("add local player", () =>
@@ -188,11 +216,13 @@ namespace osu.Game.Tests.Visual.Gameplay
});
}
private void createLeaderboard()
private void createLeaderboard(bool partial = false)
{
AddStep("create leaderboard", () =>
{
Child = leaderboard = new TestGameplayLeaderboard
leaderboardProvider.Scores.Clear();
leaderboardProvider.IsPartial = partial;
Child = leaderboard = new TestDrawableGameplayLeaderboard
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -205,11 +235,11 @@ namespace osu.Game.Tests.Visual.Gameplay
private void createLeaderboardScore(BindableLong score, APIUser user, bool isTracked = false)
{
var leaderboardScore = leaderboard.Add(user, isTracked);
leaderboardScore.TotalScore.BindTo(score);
var leaderboardScore = new GameplayLeaderboardScore(user, isTracked, score);
leaderboardProvider.Scores.Add(leaderboardScore);
}
private partial class TestGameplayLeaderboard : GameplayLeaderboard
private partial class TestDrawableGameplayLeaderboard : DrawableGameplayLeaderboard
{
public float Spacing => Flow.Spacing.Y;
@@ -220,8 +250,17 @@ namespace osu.Game.Tests.Visual.Gameplay
return scoreItem != null && scoreItem.ScorePosition == expectedPosition;
}
public IEnumerable<GameplayLeaderboardScore> GetAllScoresForUsername(string username)
public IEnumerable<DrawableGameplayLeaderboardScore> GetAllScoresForUsername(string username)
=> Flow.Where(i => i.User?.Username == username);
public IEnumerable<DrawableGameplayLeaderboardScore> AllScores => Flow;
}
private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider
{
IBindableList<GameplayLeaderboardScore> IGameplayLeaderboardProvider.Scores => Scores;
public BindableList<GameplayLeaderboardScore> Scores { get; } = new BindableList<GameplayLeaderboardScore>();
public bool IsPartial { get; set; }
}
}
}
@@ -1,124 +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.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select;
using osu.Game.Tests.Gameplay;
namespace osu.Game.Tests.Visual.Gameplay
{
public partial class TestSceneSoloGameplayLeaderboard : OsuTestScene
{
[Cached(typeof(ScoreProcessor))]
private readonly ScoreProcessor scoreProcessor = TestGameplayState.Create(new OsuRuleset()).ScoreProcessor;
private readonly BindableList<ScoreInfo> scores = new BindableList<ScoreInfo>();
private readonly Bindable<bool> configVisibility = new Bindable<bool>();
private readonly Bindable<PlayBeatmapDetailArea.TabType> beatmapTabType = new Bindable<PlayBeatmapDetailArea.TabType>();
private SoloGameplayLeaderboard leaderboard = null!;
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility);
config.BindWith(OsuSetting.BeatmapDetailTab, beatmapTabType);
}
[SetUpSteps]
public void SetUpSteps()
{
AddStep("clear scores", () => scores.Clear());
AddStep("create component", () =>
{
var trackingUser = new APIUser
{
Username = "local user",
Id = 2,
};
Child = leaderboard = new SoloGameplayLeaderboard(trackingUser)
{
Scores = { BindTarget = scores },
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AlwaysVisible = { Value = false },
Expanded = { Value = true },
};
});
AddStep("add scores", () => scores.AddRange(createSampleScores()));
}
[Test]
public void TestLocalUser()
{
AddSliderStep("score", 0, 1000000, 500000, v => scoreProcessor.TotalScore.Value = v);
AddSliderStep("accuracy", 0f, 1f, 0.5f, v => scoreProcessor.Accuracy.Value = v);
AddSliderStep("combo", 0, 10000, 0, v => scoreProcessor.HighestCombo.Value = v);
AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value);
}
[TestCase(PlayBeatmapDetailArea.TabType.Local, 51)]
[TestCase(PlayBeatmapDetailArea.TabType.Global, null)]
[TestCase(PlayBeatmapDetailArea.TabType.Country, null)]
[TestCase(PlayBeatmapDetailArea.TabType.Friends, null)]
public void TestTrackedScorePosition(PlayBeatmapDetailArea.TabType tabType, int? expectedOverflowIndex)
{
AddStep($"change TabType to {tabType}", () => beatmapTabType.Value = tabType);
AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50));
AddStep("add one more score", () => scores.Add(new ScoreInfo { User = new APIUser { Username = "New player 1" }, TotalScore = RNG.Next(600000, 1000000) }));
AddUntilStep("wait for sort", () => leaderboard.ChildrenOfType<GameplayLeaderboardScore>().First().ScorePosition != null);
if (expectedOverflowIndex == null)
AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null);
else
AddUntilStep($"tracked player is #{expectedOverflowIndex}", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(expectedOverflowIndex));
}
[Test]
public void TestVisibility()
{
AddStep("set config visible true", () => configVisibility.Value = true);
AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1);
AddStep("set config visible false", () => configVisibility.Value = false);
AddUntilStep("leaderboard not visible", () => leaderboard.Alpha == 0);
AddStep("set always visible", () => leaderboard.AlwaysVisible.Value = true);
AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1);
AddStep("set config visible true", () => configVisibility.Value = true);
AddAssert("leaderboard still visible", () => leaderboard.Alpha == 1);
}
private static List<ScoreInfo> createSampleScores()
{
return new[]
{
new ScoreInfo { User = new APIUser { Username = @"peppy" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"smoogipoo" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"spaceman_atlas" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"frenzibyte" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"Susko3" }, TotalScore = RNG.Next(500000, 1000000) },
}.Concat(Enumerable.Range(0, 44).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList();
}
}
}
@@ -2,12 +2,14 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
@@ -48,7 +50,7 @@ namespace osu.Game.Tests.Visual.Gameplay
}
[Test]
public void TestVideoSize()
public void TestVideo()
{
AddStep("load storyboard with only video", () =>
{
@@ -56,6 +58,7 @@ namespace osu.Game.Tests.Visual.Gameplay
loadStoryboard("storyboard_only_video.osu", s => s.Beatmap.WidescreenStoryboard = false);
});
AddAssert("storyboard video present in hierarchy", () => this.ChildrenOfType<DrawableStoryboardVideo>().Any());
AddAssert("storyboard is correct width", () => Precision.AlmostEquals(storyboard?.Width ?? 0f, 480 * 16 / 9f));
}
@@ -10,6 +10,7 @@ using Moq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
@@ -20,6 +21,7 @@ using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
namespace osu.Game.Tests.Visual.Multiplayer
{
@@ -29,11 +31,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
protected readonly BindableList<MultiplayerRoomUser> MultiplayerUsers = new BindableList<MultiplayerRoomUser>();
protected MultiplayerGameplayLeaderboard? Leaderboard { get; private set; }
protected MultiplayerLeaderboardProvider? LeaderboardProvider { get; private set; }
protected DrawableGameplayLeaderboard? Leaderboard { get; private set; }
protected virtual MultiplayerRoomUser CreateUser(int userId) => new MultiplayerRoomUser(userId);
protected abstract MultiplayerGameplayLeaderboard CreateLeaderboard();
protected abstract MultiplayerLeaderboardProvider CreateLeaderboardProvider();
private readonly BindableList<int> multiplayerUserIds = new BindableList<int>();
private readonly BindableDictionary<int, SpectatorState> watchedUserStates = new BindableDictionary<int, SpectatorState>();
@@ -124,19 +128,38 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("create leaderboard", () =>
{
Leaderboard?.Expire();
Clear(true);
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
LoadComponentAsync(Leaderboard = CreateLeaderboard(), Add);
LoadComponentAsync(LeaderboardProvider = CreateLeaderboardProvider(), Add);
Add(new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = [(typeof(IGameplayLeaderboardProvider), LeaderboardProvider)],
Child = Leaderboard = new DrawableGameplayLeaderboard
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}
});
});
AddUntilStep("wait for load", () => Leaderboard!.IsLoaded);
AddStep("check watch requests were sent", () =>
AddUntilStep("check watch requests were sent", () =>
{
foreach (var user in MultiplayerUsers)
spectatorClient.Verify(s => s.WatchUser(user.UserID), Times.Once);
try
{
foreach (var user in MultiplayerUsers)
spectatorClient.Verify(s => s.WatchUser(user.UserID), Times.Once);
return true;
}
catch (MockException)
{
return false;
}
});
}
@@ -159,10 +182,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
return false;
});
AddStep("check stop watching requests were sent", () =>
AddUntilStep("check stop watching requests were sent", () =>
{
foreach (var user in MultiplayerUsers)
spectatorClient.Verify(s => s.StopWatchingUser(user.UserID), Times.Once);
try
{
foreach (var user in MultiplayerUsers)
spectatorClient.Verify(s => s.StopWatchingUser(user.UserID), Times.Once);
return true;
}
catch (MockException)
{
return false;
}
});
}
@@ -204,12 +235,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
header.Combo++;
header.MaxCombo = Math.Max(header.MaxCombo, header.Combo);
header.Statistics[HitResult.Meh]++;
header.TotalScore += 50;
break;
default:
header.Combo++;
header.MaxCombo = Math.Max(header.MaxCombo, header.Combo);
header.Statistics[HitResult.Great]++;
header.TotalScore += 300;
break;
}
@@ -218,3 +251,4 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
}
}
@@ -9,15 +9,16 @@ using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
namespace osu.Game.Tests.Visual.Multiplayer
{
public partial class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene
{
private Dictionary<int, ManualClock> clocks = null!;
private MultiSpectatorLeaderboard? leaderboard;
private MultiSpectatorLeaderboardProvider? leaderboardProvider;
private DrawableGameplayLeaderboard leaderboard = null!;
[SetUpSteps]
public override void SetUpSteps()
@@ -29,7 +30,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("reset", () =>
{
leaderboard?.RemoveAndDisposeImmediately();
Clear(true);
clocks = new Dictionary<int, ManualClock>
{
@@ -48,21 +49,27 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value);
LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray())
LoadComponentAsync(leaderboardProvider = new MultiSpectatorLeaderboardProvider(clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()), Add);
Add(new DependencyProvidingContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Expanded = { Value = true }
}, Add);
RelativeSizeAxes = Axes.Both,
CachedDependencies = [(typeof(IGameplayLeaderboardProvider), leaderboardProvider)],
Child = leaderboard = new DrawableGameplayLeaderboard
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Expanded = { Value = true }
}
});
});
AddUntilStep("wait for load", () => leaderboard!.IsLoaded);
AddUntilStep("wait for user population", () => leaderboard.ChildrenOfType<GameplayLeaderboardScore>().Count() == 2);
AddUntilStep("wait for load", () => leaderboard.IsLoaded);
AddUntilStep("wait for user population", () => leaderboard.ChildrenOfType<DrawableGameplayLeaderboardScore>().Count() == 2);
AddStep("add clock sources", () =>
{
foreach ((int userId, var clock) in clocks)
leaderboard!.AddClock(userId, clock);
leaderboardProvider!.AddClock(userId, clock);
});
}
@@ -123,6 +130,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
=> AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time);
private void assertCombo(int userId, int expectedCombo)
=> AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo);
=> AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<DrawableGameplayLeaderboardScore>().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo);
}
}
@@ -560,7 +560,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType<PlayerArea>().Single(p => p.UserId == userId);
private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.OnlineID == userId);
private DrawableGameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType<DrawableGameplayLeaderboardScore>().Single(s => s.User?.OnlineID == userId);
private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray();
}
@@ -9,7 +9,7 @@ using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
namespace osu.Game.Tests.Visual.Multiplayer
{
@@ -25,27 +25,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
return user;
}
protected override MultiplayerGameplayLeaderboard CreateLeaderboard()
{
return new TestLeaderboard(MultiplayerUsers.ToArray())
protected override MultiplayerLeaderboardProvider CreateLeaderboardProvider() =>
new TestLeaderboard(MultiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
}
[Test]
public void TestPerUserMods()
{
AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)Leaderboard!).UserMods[0], Is.Empty));
AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)LeaderboardProvider!).UserMods[0], Is.Empty));
AddStep("last user has NF mod", () =>
{
Assert.That(((TestLeaderboard)Leaderboard!).UserMods[TOTAL_USERS - 1], Has.One.Items);
Assert.That(((TestLeaderboard)Leaderboard).UserMods[TOTAL_USERS - 1].Single(), Is.TypeOf<OsuModNoFail>());
Assert.That(((TestLeaderboard)LeaderboardProvider!).UserMods[TOTAL_USERS - 1], Has.One.Items);
Assert.That(((TestLeaderboard)LeaderboardProvider).UserMods[TOTAL_USERS - 1].Single(), Is.TypeOf<OsuModNoFail>());
});
}
private partial class TestLeaderboard : MultiplayerGameplayLeaderboard
private partial class TestLeaderboard : MultiplayerLeaderboardProvider
{
public Dictionary<int, IReadOnlyList<Mod>> UserMods => UserScores.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ScoreProcessor.Mods);
@@ -7,6 +7,7 @@ using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
namespace osu.Game.Tests.Visual.Multiplayer
{
@@ -24,8 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
return user;
}
protected override MultiplayerGameplayLeaderboard CreateLeaderboard() =>
new MultiplayerGameplayLeaderboard(MultiplayerUsers.ToArray())
protected override MultiplayerLeaderboardProvider CreateLeaderboardProvider() =>
new MultiplayerLeaderboardProvider(MultiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -39,17 +40,17 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
LoadComponentAsync(new MatchScoreDisplay
{
Team1Score = { BindTarget = Leaderboard!.TeamScores[0] },
Team2Score = { BindTarget = Leaderboard.TeamScores[1] }
Team1Score = { BindTarget = LeaderboardProvider!.TeamScores[0] },
Team2Score = { BindTarget = LeaderboardProvider.TeamScores[1] }
}, Add);
LoadComponentAsync(new GameplayMatchScoreDisplay
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Team1Score = { BindTarget = Leaderboard.TeamScores[0] },
Team2Score = { BindTarget = Leaderboard.TeamScores[1] },
Expanded = { BindTarget = Leaderboard.Expanded },
Team1Score = { BindTarget = LeaderboardProvider.TeamScores[0] },
Team2Score = { BindTarget = LeaderboardProvider.TeamScores[1] },
Expanded = { BindTarget = Leaderboard!.Expanded },
}, Add);
});
}
@@ -168,6 +168,19 @@ namespace osu.Game.Tests.Visual.Ranking
};
});
public static List<HitEvent> CreateHitEvents(double offset = 0, int count = 50)
{
var hitEvents = new List<HitEvent>();
for (int i = 0; i < count; i++)
{
for (int j = 0; j < count; j++)
hitEvents.Add(new HitEvent(offset, 1.0, HitResult.Perfect, placeholder_object, placeholder_object, null));
}
return hitEvents;
}
public static List<HitEvent> CreateDistributedHitEvents(double centre = 0, double range = 25)
{
var hitEvents = new List<HitEvent>();
@@ -1,11 +1,14 @@
// 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.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings.Sections.Audio;
@@ -70,16 +73,54 @@ namespace osu.Game.Tests.Visual.Settings
AddStep("clear history", () => tracker.ClearHistory());
}
[Test]
public void TestRounding()
{
AddStep("set new score", () => statics.SetValue(Static.LastLocalUserScore, new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateHitEvents(0.6),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
}));
checkButtonEnabled();
AddStep("click button", () => adjustControl.ChildrenOfType<Button>().Single().TriggerClick());
checkButtonDisabled();
AddAssert("global offset set correctly", () => localConfig.Get<double>(OsuSetting.AudioOffset), () => Is.EqualTo(-1));
}
[Test]
public void TestNegligibleChangeNotApplicable()
{
AddStep("set new score", () => statics.SetValue(Static.LastLocalUserScore, new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateHitEvents(0.5),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
}));
checkButtonDisabled();
AddStep("adjust global offset", () => localConfig.SetValue(OsuSetting.AudioOffset, 50.0));
checkButtonEnabled();
AddStep("click button", () => adjustControl.ChildrenOfType<Button>().Single().TriggerClick());
checkButtonDisabled();
AddAssert("global offset set correctly", () => localConfig.Get<double>(OsuSetting.AudioOffset), () => Is.EqualTo(0));
AddStep("clear history", () => tracker.ClearHistory());
}
[Test]
public void TestBehaviour()
{
AddStep("set score with -20ms", () => setScore(-20));
AddAssert("suggested global offset is 20ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(20));
checkButtonEnabled();
AddStep("clear history", () => tracker.ClearHistory());
checkButtonDisabled();
AddStep("set score with 40ms", () => setScore(40));
checkButtonEnabled();
AddAssert("suggested global offset is -40ms", () => adjustControl.SuggestedOffset.Value, () => Is.EqualTo(-40));
AddStep("clear history", () => tracker.ClearHistory());
checkButtonDisabled();
}
[Test]
@@ -111,6 +152,16 @@ namespace osu.Game.Tests.Visual.Settings
AddStep("clear history", () => tracker.ClearHistory());
}
private void checkButtonDisabled()
{
AddAssert("button is disabled", () => adjustControl.ChildrenOfType<Button>().Single().Enabled.Value, () => Is.False);
}
private void checkButtonEnabled()
{
AddAssert("button is enabled", () => adjustControl.ChildrenOfType<Button>().Single().Enabled.Value, () => Is.True);
}
private void setScore(double averageHitError)
{
statics.SetValue(Static.LastLocalUserScore, new ScoreInfo
@@ -0,0 +1,271 @@
// 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.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Tests.Resources;
using osuTK.Input;
using Realms;
namespace osu.Game.Tests.Visual.SongSelect
{
public partial class TestSceneCollectionDropdown : OsuManualInputManagerTestScene
{
private BeatmapManager beatmapManager = null!;
private CollectionDropdown dropdown = null!;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[BackgroundDependencyLoader]
private void load(GameHost host)
{
Dependencies.Cache(new RealmRulesetStore(Realm));
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
}
[SetUp]
public void SetUp() => Schedule(() =>
{
writeAndRefresh(r => r.RemoveAll<BeatmapCollection>());
Child = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Child = dropdown = new CollectionDropdown
{
Width = 300,
Y = 100,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
};
});
[Test]
public void TestEmptyCollectionFilterContainsAllBeatmaps()
{
assertCollectionDropdownContains("All beatmaps");
assertCollectionHeaderDisplays("All beatmaps");
}
[Test]
public void TestCollectionAddedToDropdown()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
assertCollectionDropdownContains("1");
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionsCleared()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3"))));
AddAssert("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll<BeatmapCollection>()));
AddAssert("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
}
[Test]
public void TestCollectionRemovedFromDropdown()
{
BeatmapCollection first = null!;
AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first)));
assertCollectionDropdownContains("1", false);
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionRenamed()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select collection", () => dropdown.Current.Value = dropdown.ItemSource.ElementAt(1));
addExpandHeaderStep();
AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First"));
assertCollectionDropdownContains("First");
assertCollectionHeaderDisplays("First");
}
[Test]
public void TestAllBeatmapFilterDoesNotHaveAddButton()
{
addExpandHeaderStep();
AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0)));
AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent);
}
[Test]
public void TestCollectionFilterHasAddButton()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1)));
AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent);
}
[Test]
public void TestButtonDisabledAndEnabledWithBeatmapChanges()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value);
AddStep("set dummy beatmap", () => Beatmap.SetDefault());
AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value);
}
[Test]
public void TestButtonChangesWhenAddedAndRemovedFromCollection()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear()));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestButtonAddsAndRemovesBeatmap()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestManageCollectionsFilterIsNotSelected()
{
bool received = false;
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List<string> { "abc" }))));
assertCollectionDropdownContains("1");
AddStep("select collection", () =>
{
InputManager.MoveMouseTo(getCollectionDropdownItemAt(1));
InputManager.Click(MouseButton.Left);
});
addExpandHeaderStep();
AddStep("watch for filter requests", () =>
{
received = false;
dropdown.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
});
AddStep("click manage collections filter", () =>
{
int lastItemIndex = dropdown.ChildrenOfType<CollectionDropdown>().Single().Items.Count() - 1;
InputManager.MoveMouseTo(getCollectionDropdownItemAt(lastItemIndex));
InputManager.Click(MouseButton.Left);
});
AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1");
AddAssert("filter request not fired", () => !received);
}
private void writeAndRefresh(Action<Realm> action) => Realm.Write(r =>
{
action(r);
r.Refresh();
});
private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All<BeatmapCollection>().First());
private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true)
=> AddUntilStep($"collection dropdown header displays '{collectionName}'",
() => shouldDisplay == dropdown.ChildrenOfType<CollectionDropdown.OsuDropdownHeader>().Any(h => h.ChildrenOfType<SpriteText>().Any(t => t.Text == collectionName)));
private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon));
private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) =>
AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'",
// A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872
() => shouldContain == dropdown.ChildrenOfType<Menu.DrawableMenuItem>().Any(i => i.ChildrenOfType<CompositeDrawable>().OfType<IHasText>().First().Text == collectionName));
private IconButton getAddOrRemoveButton(int index)
=> getCollectionDropdownItemAt(index).ChildrenOfType<IconButton>().Single();
private void addExpandHeaderStep() => AddStep("expand header", () =>
{
InputManager.MoveMouseTo(dropdown.ChildrenOfType<CollectionDropdown.OsuDropdownHeader>().Single());
InputManager.Click(MouseButton.Left);
});
private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () =>
{
InputManager.MoveMouseTo(getAddOrRemoveButton(index));
InputManager.Click(MouseButton.Left);
});
private Menu.DrawableMenuItem getCollectionDropdownItemAt(int index)
{
// todo: we should be able to use Items, but apparently that's not guaranteed to be ordered... see: https://github.com/ppy/osu-framework/pull/6079
CollectionFilterMenuItem item = dropdown.ChildrenOfType<CollectionDropdown>().Single().ItemSource.ElementAt(index);
return dropdown.ChildrenOfType<Menu.DrawableMenuItem>().Single(i => i.Item.Text.Value == item.CollectionName);
}
}
}
@@ -1,57 +1,18 @@
// 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.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Screens.Select;
using osu.Game.Tests.Resources;
using osuTK.Input;
using Realms;
namespace osu.Game.Tests.Visual.SongSelect
{
public partial class TestSceneFilterControl : OsuManualInputManagerTestScene
{
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
private BeatmapManager beatmapManager = null!;
private FilterControl control = null!;
[BackgroundDependencyLoader]
private void load(GameHost host)
{
Dependencies.Cache(new RealmRulesetStore(Realm));
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
base.Content.AddRange(new Drawable[]
{
Content
});
}
[SetUp]
public void SetUp() => Schedule(() =>
{
writeAndRefresh(r => r.RemoveAll<BeatmapCollection>());
Child = control = new FilterControl
Child = new FilterControl
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@@ -59,216 +20,5 @@ namespace osu.Game.Tests.Visual.SongSelect
Height = FilterControl.HEIGHT,
};
});
[Test]
public void TestEmptyCollectionFilterContainsAllBeatmaps()
{
assertCollectionDropdownContains("All beatmaps");
assertCollectionHeaderDisplays("All beatmaps");
}
[Test]
public void TestCollectionAddedToDropdown()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
assertCollectionDropdownContains("1");
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionsCleared()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3"))));
AddAssert("check count 5", () => control.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll<BeatmapCollection>()));
AddAssert("check count 2", () => control.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
}
[Test]
public void TestCollectionRemovedFromDropdown()
{
BeatmapCollection first = null!;
AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first)));
assertCollectionDropdownContains("1", false);
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionRenamed()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select collection", () =>
{
var dropdown = control.ChildrenOfType<CollectionDropdown>().Single();
dropdown.Current.Value = dropdown.ItemSource.ElementAt(1);
});
addExpandHeaderStep();
AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First"));
assertCollectionDropdownContains("First");
assertCollectionHeaderDisplays("First");
}
[Test]
public void TestAllBeatmapFilterDoesNotHaveAddButton()
{
addExpandHeaderStep();
AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0)));
AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent);
}
[Test]
public void TestCollectionFilterHasAddButton()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1)));
AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent);
}
[Test]
public void TestButtonDisabledAndEnabledWithBeatmapChanges()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value);
AddStep("set dummy beatmap", () => Beatmap.SetDefault());
AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value);
}
[Test]
public void TestButtonChangesWhenAddedAndRemovedFromCollection()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear()));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestButtonAddsAndRemovesBeatmap()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestManageCollectionsFilterIsNotSelected()
{
bool received = false;
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List<string> { "abc" }))));
assertCollectionDropdownContains("1");
AddStep("select collection", () =>
{
InputManager.MoveMouseTo(getCollectionDropdownItemAt(1));
InputManager.Click(MouseButton.Left);
});
addExpandHeaderStep();
AddStep("watch for filter requests", () =>
{
received = false;
control.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
});
AddStep("click manage collections filter", () =>
{
int lastItemIndex = control.ChildrenOfType<CollectionDropdown>().Single().Items.Count() - 1;
InputManager.MoveMouseTo(getCollectionDropdownItemAt(lastItemIndex));
InputManager.Click(MouseButton.Left);
});
AddAssert("collection filter still selected", () => control.CreateCriteria().CollectionBeatmapMD5Hashes?.Any() == true);
AddAssert("filter request not fired", () => !received);
}
private void writeAndRefresh(Action<Realm> action) => Realm.Write(r =>
{
action(r);
r.Refresh();
});
private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All<BeatmapCollection>().First());
private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true)
=> AddUntilStep($"collection dropdown header displays '{collectionName}'",
() => shouldDisplay == (control.ChildrenOfType<CollectionDropdown.CollectionDropdownHeader>().Single().ChildrenOfType<SpriteText>().First().Text == collectionName));
private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon));
private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) =>
AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'",
// A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872
() => shouldContain == control.ChildrenOfType<Menu.DrawableMenuItem>().Any(i => i.ChildrenOfType<CompositeDrawable>().OfType<IHasText>().First().Text == collectionName));
private IconButton getAddOrRemoveButton(int index)
=> getCollectionDropdownItemAt(index).ChildrenOfType<IconButton>().Single();
private void addExpandHeaderStep() => AddStep("expand header", () =>
{
InputManager.MoveMouseTo(control.ChildrenOfType<CollectionDropdown.CollectionDropdownHeader>().Single());
InputManager.Click(MouseButton.Left);
});
private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () =>
{
InputManager.MoveMouseTo(getAddOrRemoveButton(index));
InputManager.Click(MouseButton.Left);
});
private Menu.DrawableMenuItem getCollectionDropdownItemAt(int index)
{
// todo: we should be able to use Items, but apparently that's not guaranteed to be ordered... see: https://github.com/ppy/osu-framework/pull/6079
CollectionFilterMenuItem item = control.ChildrenOfType<CollectionDropdown>().Single().ItemSource.ElementAt(index);
return control.ChildrenOfType<Menu.DrawableMenuItem>().Single(i => i.Item.Text.Value == item.CollectionName);
}
}
}
@@ -20,7 +20,7 @@ using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Collections
namespace osu.Game.Tests.Visual.SongSelect
{
public partial class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene
{
@@ -0,0 +1,272 @@
// 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.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Tests.Resources;
using osuTK.Input;
using Realms;
using CollectionDropdown = osu.Game.Screens.SelectV2.CollectionDropdown;
namespace osu.Game.Tests.Visual.SongSelectV2
{
public partial class TestSceneCollectionDropdown : OsuManualInputManagerTestScene
{
private BeatmapManager beatmapManager = null!;
private CollectionDropdown dropdown = null!;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[BackgroundDependencyLoader]
private void load(GameHost host)
{
Dependencies.Cache(new RealmRulesetStore(Realm));
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
}
[SetUp]
public void SetUp() => Schedule(() =>
{
writeAndRefresh(r => r.RemoveAll<BeatmapCollection>());
Child = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Child = dropdown = new CollectionDropdown
{
Width = 300,
Y = 100,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
};
});
[Test]
public void TestEmptyCollectionFilterContainsAllBeatmaps()
{
assertCollectionDropdownContains("All beatmaps");
assertCollectionHeaderDisplays("All beatmaps");
}
[Test]
public void TestCollectionAddedToDropdown()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
assertCollectionDropdownContains("1");
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionsCleared()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3"))));
AddAssert("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll<BeatmapCollection>()));
AddAssert("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
}
[Test]
public void TestCollectionRemovedFromDropdown()
{
BeatmapCollection first = null!;
AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first)));
assertCollectionDropdownContains("1", false);
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionRenamed()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select collection", () => dropdown.Current.Value = dropdown.ItemSource.ElementAt(1));
addExpandHeaderStep();
AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First"));
assertCollectionDropdownContains("First");
assertCollectionHeaderDisplays("First");
}
[Test]
public void TestAllBeatmapFilterDoesNotHaveAddButton()
{
addExpandHeaderStep();
AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0)));
AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent);
}
[Test]
public void TestCollectionFilterHasAddButton()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1)));
AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent);
}
[Test]
public void TestButtonDisabledAndEnabledWithBeatmapChanges()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value);
AddStep("set dummy beatmap", () => Beatmap.SetDefault());
AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value);
}
[Test]
public void TestButtonChangesWhenAddedAndRemovedFromCollection()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear()));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestButtonAddsAndRemovesBeatmap()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
addClickAddOrRemoveButtonStep(1);
AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestManageCollectionsFilterIsNotSelected()
{
bool received = false;
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List<string> { "abc" }))));
assertCollectionDropdownContains("1");
AddStep("select collection", () =>
{
InputManager.MoveMouseTo(getCollectionDropdownItemAt(1));
InputManager.Click(MouseButton.Left);
});
addExpandHeaderStep();
AddStep("watch for filter requests", () =>
{
received = false;
dropdown.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
});
AddStep("click manage collections filter", () =>
{
int lastItemIndex = dropdown.ChildrenOfType<CollectionDropdown>().Single().Items.Count() - 1;
InputManager.MoveMouseTo(getCollectionDropdownItemAt(lastItemIndex));
InputManager.Click(MouseButton.Left);
});
AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1");
AddAssert("filter request not fired", () => !received);
}
private void writeAndRefresh(Action<Realm> action) => Realm.Write(r =>
{
action(r);
r.Refresh();
});
private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All<BeatmapCollection>().First());
private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true)
=> AddUntilStep($"collection dropdown header displays '{collectionName}'",
() => shouldDisplay == dropdown.ChildrenOfType<CollectionDropdown.ShearedDropdownHeader>().Any(h => h.ChildrenOfType<SpriteText>().Any(t => t.Text == collectionName)));
private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon));
private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) =>
AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'",
// A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872
() => shouldContain == dropdown.ChildrenOfType<Menu.DrawableMenuItem>().Any(i => i.ChildrenOfType<CompositeDrawable>().OfType<IHasText>().First().Text == collectionName));
private IconButton getAddOrRemoveButton(int index)
=> getCollectionDropdownItemAt(index).ChildrenOfType<IconButton>().Single();
private void addExpandHeaderStep() => AddStep("expand header", () =>
{
InputManager.MoveMouseTo(dropdown.ChildrenOfType<CollectionDropdown.ShearedDropdownHeader>().Single());
InputManager.Click(MouseButton.Left);
});
private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () =>
{
InputManager.MoveMouseTo(getAddOrRemoveButton(index));
InputManager.Click(MouseButton.Left);
});
private Menu.DrawableMenuItem getCollectionDropdownItemAt(int index)
{
// todo: we should be able to use Items, but apparently that's not guaranteed to be ordered... see: https://github.com/ppy/osu-framework/pull/6079
CollectionFilterMenuItem item = dropdown.ChildrenOfType<CollectionDropdown>().Single().ItemSource.ElementAt(index);
return dropdown.ChildrenOfType<Menu.DrawableMenuItem>().Single(i => i.Item.Text.Value == item.CollectionName);
}
}
}
@@ -1,17 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Carousel;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.UserInterface;
@@ -66,6 +72,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddToggleStep("mania ruleset", v => Ruleset.Value = v ? new ManiaRuleset().RulesetInfo : new OsuRuleset().RulesetInfo);
}
[Test]
public void TestLocalRank()
{
foreach (var rank in Enum.GetValues<ScoreRank>())
{
AddStep($"set {rank.GetDescription()} rank", () => this.ChildrenOfType<UpdateableRank>().ForEach(p =>
{
p.Show();
p.Rank = rank;
}));
}
}
protected override Drawable CreateContent()
{
return new FillFlowContainer
@@ -1,17 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Carousel;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.UserInterface;
@@ -66,6 +72,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddToggleStep("mania ruleset", v => Ruleset.Value = v ? new ManiaRuleset().RulesetInfo : new OsuRuleset().RulesetInfo);
}
[Test]
public void TestLocalRank()
{
foreach (var rank in Enum.GetValues<ScoreRank>())
{
AddStep($"set {rank.GetDescription()} rank", () => this.ChildrenOfType<UpdateableRank>().ForEach(p =>
{
p.Show();
p.Rank = rank;
}));
}
}
protected override Drawable CreateContent()
{
return new FillFlowContainer
@@ -141,7 +141,7 @@ namespace osu.Game.Beatmaps.Drawables
Add(countText = new OsuSpriteText
{
Font = OsuFont.Default.With(size: 12),
Font = OsuFont.Style.Caption1,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Padding = new MarginPadding { Bottom = 1 }
@@ -0,0 +1,51 @@
// 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.Globalization;
using System.Numerics;
using osu.Game.Utils;
namespace osu.Game.Extensions
{
public static class NumberFormattingExtensions
{
/// <summary>
/// For a given numeric type, return a formatted string in the standard format we use for display everywhere.
/// </summary>
/// <param name="value">The numeric value.</param>
/// <param name="maxDecimalDigits">The maximum number of decimals to be considered in the original value.</param>
/// <param name="asPercentage">Whether the output should be a percentage. For integer types, 0-100 is mapped to 0-100%; for other types 0-1 is mapped to 0-100%.</param>
/// <returns>The formatted output.</returns>
public static string ToStandardFormattedString<T>(this T value, int maxDecimalDigits, bool asPercentage) where T : struct, INumber<T>, IMinMaxValue<T>
{
double floatValue = double.CreateTruncating(value);
decimal decimalPrecision = normalise(decimal.CreateTruncating(value), maxDecimalDigits);
// Find the number of significant digits (we could have less than maxDecimalDigits after normalize())
int significantDigits = FormatUtils.FindPrecision(decimalPrecision);
if (asPercentage)
{
if (value is int)
floatValue /= 100;
return floatValue.ToString($@"0.{new string('0', Math.Max(0, significantDigits - 2))}%", CultureInfo.InvariantCulture);
}
string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty;
return FormattableString.Invariant($"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}")}");
}
/// <summary>
/// Removes all non-significant digits, keeping at most a requested number of decimal digits.
/// </summary>
/// <param name="d">The decimal to normalize.</param>
/// <param name="sd">The maximum number of decimal digits to keep. The final result may have fewer decimal digits than this value.</param>
/// <returns>The normalised decimal.</returns>
private static decimal normalise(decimal d, int sd)
=> decimal.Parse(Math.Round(d, sd).ToString(string.Concat("0.", new string('#', sd)), CultureInfo.InvariantCulture), CultureInfo.InvariantCulture);
}
}
+6 -1
View File
@@ -11,7 +11,7 @@ namespace osu.Game.Graphics.Carousel
/// </summary>
public sealed class CarouselItem : IComparable<CarouselItem>
{
public const float DEFAULT_HEIGHT = 50;
public const float DEFAULT_HEIGHT = 45;
/// <summary>
/// The model this item is representing.
@@ -44,6 +44,11 @@ namespace osu.Game.Graphics.Carousel
/// </summary>
public bool IsExpanded { get; set; }
/// <summary>
/// The number of nested items underneath this header. Should only be used for headers of groups.
/// </summary>
public int NestedItemCount { get; set; }
public CarouselItem(object model)
{
Model = model;
@@ -134,7 +134,7 @@ namespace osu.Game.Graphics.Containers
protected virtual DrawableLinkCompiler CreateLinkCompiler(ITextPart textPart) => new DrawableLinkCompiler(textPart);
protected override FillFlowContainer CreateFlow() => new LinkFlow();
protected override InnerFlow CreateFlow() => new LinkFlow();
private partial class LinkFlow : InnerFlow
{
@@ -1,9 +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.Numerics;
using System.Globalization;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@@ -11,7 +9,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Utils;
using osu.Game.Extensions;
namespace osu.Game.Graphics.UserInterface
{
@@ -85,35 +83,6 @@ namespace osu.Game.Graphics.UserInterface
channel.Play();
}
public LocalisableString GetDisplayableValue(T value)
{
if (CurrentNumber.IsInteger)
return int.CreateTruncating(value).ToString("N0");
double floatValue = double.CreateTruncating(value);
decimal decimalPrecision = normalise(decimal.CreateTruncating(CurrentNumber.Precision), max_decimal_digits);
// Find the number of significant digits (we could have less than 5 after normalize())
int significantDigits = FormatUtils.FindPrecision(decimalPrecision);
if (DisplayAsPercentage)
{
return floatValue.ToString($@"P{Math.Max(0, significantDigits - 2)}");
}
string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty;
return $"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}")}";
}
/// <summary>
/// Removes all non-significant digits, keeping at most a requested number of decimal digits.
/// </summary>
/// <param name="d">The decimal to normalize.</param>
/// <param name="sd">The maximum number of decimal digits to keep. The final result may have fewer decimal digits than this value.</param>
/// <returns>The normalised decimal.</returns>
private decimal normalise(decimal d, int sd)
=> decimal.Parse(Math.Round(d, sd).ToString(string.Concat("0.", new string('#', sd)), CultureInfo.InvariantCulture), CultureInfo.InvariantCulture);
public LocalisableString GetDisplayableValue(T value) => value.ToStandardFormattedString(max_decimal_digits, DisplayAsPercentage);
}
}
@@ -69,6 +69,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString SuggestedOffsetNote => new TranslatableString(getKey(@"suggested_offset_note"), @"Play a few beatmaps to receive a suggested offset!");
/// <summary>
/// "Based on the last {0} play(s), your offset is set correctly!"
/// </summary>
public static LocalisableString SuggestedOffsetCorrect(int plays) => new TranslatableString(getKey(@"suggested_offset_correct"), @"Based on the last {0} play(s), your offset is set correctly!", plays);
/// <summary>
/// "Based on the last {0} play(s), the suggested offset is {1} ms."
/// </summary>
@@ -54,6 +54,9 @@ namespace osu.Game.Online.Leaderboards
lastFetchCompletionSource?.TrySetCanceled();
scores.Value = null;
if (newCriteria.Beatmap == null || newCriteria.Ruleset == null)
return Task.FromResult<LeaderboardScores?>(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected));
switch (newCriteria.Scope)
{
case BeatmapLeaderboardScope.Local:
@@ -72,6 +75,21 @@ namespace osu.Game.Online.Leaderboards
default:
{
if (!api.IsLoggedIn)
return Task.FromResult<LeaderboardScores?>(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn));
if (!newCriteria.Ruleset.IsLegacyRuleset())
return Task.FromResult<LeaderboardScores?>(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable));
if (newCriteria.Beatmap.OnlineID <= 0 || newCriteria.Beatmap.Status <= BeatmapOnlineStatus.Pending)
return Task.FromResult<LeaderboardScores?>(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable));
if ((newCriteria.Scope.RequiresSupporter(newCriteria.ExactMods != null)) && !api.LocalUser.Value.IsSupporter)
return Task.FromResult<LeaderboardScores?>(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter));
if (newCriteria.Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null)
return Task.FromResult<LeaderboardScores?>(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam));
var onlineFetchCompletionSource = new TaskCompletionSource<LeaderboardScores?>();
lastFetchCompletionSource = onlineFetchCompletionSource;
@@ -92,16 +110,16 @@ namespace osu.Game.Online.Leaderboards
if (inFlightOnlineRequest != null && !newRequest.Equals(inFlightOnlineRequest))
return;
var result = new LeaderboardScores
var result = LeaderboardScores.Success
(
response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)).OrderByTotalScore(),
response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)).OrderByTotalScore().ToArray(),
response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap)
);
inFlightOnlineRequest = null;
if (onlineFetchCompletionSource.TrySetResult(result))
scores.Value = result;
};
newRequest.Failure += ex => onlineFetchCompletionSource.TrySetException(ex);
newRequest.Failure += _ => onlineFetchCompletionSource.TrySetResult(LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure));
api.Queue(inFlightOnlineRequest = newRequest);
return onlineFetchCompletionSource.Task;
}
@@ -138,7 +156,7 @@ namespace osu.Game.Online.Leaderboards
newScores = newScores.Detach().OrderByTotalScore();
scores.Value = new LeaderboardScores(newScores, null);
scores.Value = LeaderboardScores.Success(newScores.ToArray(), null);
if (localFetchCompletionSource != null && localFetchCompletionSource == lastFetchCompletionSource)
{
@@ -149,14 +167,18 @@ namespace osu.Game.Online.Leaderboards
}
public record LeaderboardCriteria(
BeatmapInfo Beatmap,
RulesetInfo Ruleset,
BeatmapInfo? Beatmap,
RulesetInfo? Ruleset,
BeatmapLeaderboardScope Scope,
Mod[]? ExactMods
);
public record LeaderboardScores(IEnumerable<ScoreInfo> TopScores, ScoreInfo? UserScore)
public record LeaderboardScores
{
public ICollection<ScoreInfo> TopScores { get; }
public ScoreInfo? UserScore { get; }
public LeaderboardFailState? FailState { get; }
public IEnumerable<ScoreInfo> AllScores
{
get
@@ -168,5 +190,26 @@ namespace osu.Game.Online.Leaderboards
yield return UserScore;
}
}
private LeaderboardScores(ICollection<ScoreInfo> topScores, ScoreInfo? userScore, LeaderboardFailState? failState)
{
TopScores = topScores;
UserScore = userScore;
FailState = failState;
}
public static LeaderboardScores Success(ICollection<ScoreInfo> topScores, ScoreInfo? userScore) => new LeaderboardScores(topScores, userScore, null);
public static LeaderboardScores Failure(LeaderboardFailState failState) => new LeaderboardScores([], null, failState);
}
public enum LeaderboardFailState
{
NetworkFailure = -1,
BeatmapUnavailable = -2,
RulesetUnavailable = -3,
NoneSelected = -4,
NotLoggedIn = -5,
NotSupporter = -6,
NoTeam = -7
}
}
@@ -7,13 +7,14 @@ namespace osu.Game.Online.Leaderboards
{
Success,
Retrieving,
NetworkFailure,
BeatmapUnavailable,
RulesetUnavailable,
NoneSelected,
NoScores,
NotLoggedIn,
NotSupporter,
NoTeam
NetworkFailure = LeaderboardFailState.NetworkFailure,
BeatmapUnavailable = LeaderboardFailState.BeatmapUnavailable,
RulesetUnavailable = LeaderboardFailState.RulesetUnavailable,
NoneSelected = LeaderboardFailState.NoneSelected,
NotLoggedIn = LeaderboardFailState.NotLoggedIn,
NotSupporter = LeaderboardFailState.NotSupporter,
NoTeam = LeaderboardFailState.NoTeam,
}
}
@@ -15,7 +15,7 @@ namespace osu.Game.Overlays.Music
/// <summary>
/// A <see cref="CollectionDropdown"/> for use in the <see cref="NowPlayingOverlay"/>.
/// </summary>
public partial class NowPlayingCollectionDropdown : CollectionDropdown
public partial class NowPlayingCollectionDropdown : CollectionDropdown // TODO: class is now unused. if we decide this isn't coming back it can be nuked.
{
protected override bool ShowManageCollectionsItem => false;
@@ -8,13 +8,13 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
@@ -109,6 +109,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
base.LoadComplete();
averageHitErrorHistory.BindCollectionChanged(updateDisplay, true);
current.BindValueChanged(_ => updateHintText());
SuggestedOffset.BindValueChanged(_ => updateHintText(), true);
}
@@ -148,17 +149,28 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
break;
}
SuggestedOffset.Value = averageHitErrorHistory.Any() ? averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset) : null;
SuggestedOffset.Value = averageHitErrorHistory.Any() ? Math.Round(averageHitErrorHistory.Average(dataPoint => dataPoint.SuggestedGlobalAudioOffset)) : null;
}
private float getXPositionForOffset(double offset) => (float)(Math.Clamp(offset, current.MinValue, current.MaxValue) / (2 * current.MaxValue));
private void updateHintText()
{
hintText.Text = SuggestedOffset.Value == null
? AudioSettingsStrings.SuggestedOffsetNote
: AudioSettingsStrings.SuggestedOffsetValueReceived(averageHitErrorHistory.Count, SuggestedOffset.Value.ToLocalisableString(@"N0"));
applySuggestion.Enabled.Value = SuggestedOffset.Value != null;
if (SuggestedOffset.Value == null)
{
applySuggestion.Enabled.Value = false;
hintText.Text = AudioSettingsStrings.SuggestedOffsetNote;
}
else if (Math.Abs(SuggestedOffset.Value.Value - current.Value) < 1)
{
applySuggestion.Enabled.Value = false;
hintText.Text = AudioSettingsStrings.SuggestedOffsetCorrect(averageHitErrorHistory.Count);
}
else
{
applySuggestion.Enabled.Value = true;
hintText.Text = AudioSettingsStrings.SuggestedOffsetValueReceived(averageHitErrorHistory.Count, SuggestedOffset.Value.Value.ToStandardFormattedString(0, false));
}
}
private partial class OffsetSliderBar : RoundedSliderBar<double>
+2
View File
@@ -34,5 +34,7 @@ namespace osu.Game.Rulesets.Mods
yield return ("Speed change", $"{SpeedChange.Value:N2}x");
}
}
public override string ExtendedIconInformation => SpeedChange.IsDefault ? string.Empty : FormattableString.Invariant($"{SpeedChange.Value:N2}x");
}
}
+1 -1
View File
@@ -1266,7 +1266,7 @@ namespace osu.Game.Screens.Edit
yield return createDifficultyCreationMenu();
yield return createDifficultySwitchMenu();
yield return new OsuMenuItemSpacer();
yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } };
yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Destructive, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } };
yield return new OsuMenuItemSpacer();
var save = new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => attemptMutationOperation(Save)) { Hotkey = new Hotkey(PlatformAction.Save) };
@@ -31,14 +31,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private readonly Bindable<bool> expandedFromTextBoxFocus = new Bindable<bool>();
private const float height = 100;
private const float width = 260;
public override bool PropagateNonPositionalInputSubTree => true;
public GameplayChatDisplay(Room room)
: base(room, leaveChannelOnDispose: false)
{
RelativeSizeAxes = Axes.X;
Background.Alpha = 0.2f;
Width = width;
}
[BackgroundDependencyLoader]
@@ -15,8 +15,8 @@ using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Users;
namespace osu.Game.Screens.OnlinePlay.Multiplayer
@@ -25,6 +25,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
protected override bool PauseOnFocusLost => false;
protected override bool ShowLeaderboard => true;
protected override UserActivity InitialActivity => new UserActivity.InMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value);
[Resolved]
@@ -33,10 +35,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private IBindable<bool> isConnected = null!;
private readonly TaskCompletionSource<bool> resultsReady = new TaskCompletionSource<bool>();
private readonly MultiplayerRoomUser[] users;
private LoadingLayer loadingDisplay = null!;
private MultiplayerGameplayLeaderboard multiplayerLeaderboard = null!;
[Cached(typeof(IGameplayLeaderboardProvider))]
private readonly MultiplayerLeaderboardProvider leaderboardProvider;
private GameplayMatchScoreDisplay teamScoreDisplay = null!;
/// <summary>
/// Construct a multiplayer player.
@@ -55,7 +60,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
AlwaysShowLeaderboard = true,
})
{
this.users = users;
leaderboardProvider = new MultiplayerLeaderboardProvider(users);
}
[BackgroundDependencyLoader]
@@ -71,26 +76,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Expanded = { BindTarget = LeaderboardExpandedState },
}, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat));
HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue });
}
protected override GameplayLeaderboard CreateGameplayLeaderboard() => multiplayerLeaderboard = new MultiplayerGameplayLeaderboard(users);
protected override void AddLeaderboardToHUD(GameplayLeaderboard leaderboard)
{
Debug.Assert(leaderboard == multiplayerLeaderboard);
HUDOverlay.LeaderboardFlow.Insert(0, leaderboard);
if (multiplayerLeaderboard.TeamScores.Count >= 2)
LoadComponentAsync(teamScoreDisplay = new GameplayMatchScoreDisplay
{
LoadComponentAsync(new GameplayMatchScoreDisplay
Expanded = { BindTarget = HUDOverlay.ShowHud },
Alpha = 0,
}, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay));
LoadComponentAsync(leaderboardProvider, loaded =>
{
AddInternal(loaded);
if (loaded.HasTeams)
{
Team1Score = { BindTarget = multiplayerLeaderboard.TeamScores.First().Value },
Team2Score = { BindTarget = multiplayerLeaderboard.TeamScores.Last().Value },
Expanded = { BindTarget = HUDOverlay.ShowHud },
}, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay));
}
teamScoreDisplay.Alpha = 1;
teamScoreDisplay.Team1Score.BindTarget = leaderboardProvider.TeamScores.First().Value;
teamScoreDisplay.Team2Score.BindTarget = leaderboardProvider.TeamScores.Last().Value;
}
});
HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue });
}
protected override void LoadAsyncComplete()
@@ -195,8 +198,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
Debug.Assert(Room.RoomID != null);
return multiplayerLeaderboard.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value, PlaylistItem, multiplayerLeaderboard.TeamScores)
return leaderboardProvider.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value, PlaylistItem, leaderboardProvider.TeamScores)
{
IsLocalPlay = true,
}
@@ -15,6 +15,7 @@ using osu.Game.Online.Rooms;
using osu.Game.Online.Spectator;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Screens.Spectate;
using osu.Game.Users;
using osuTK;
@@ -47,17 +48,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
[Cached(typeof(IGameplayLeaderboardProvider))]
private MultiSpectatorLeaderboardProvider leaderboardProvider { get; set; }
private IAggregateAudioAdjustment? boundAdjustments;
private readonly PlayerArea[] instances;
private MasterGameplayClockContainer masterClockContainer = null!;
private SpectatorSyncManager syncManager = null!;
private PlayerGrid grid = null!;
private MultiSpectatorLeaderboard leaderboard = null!;
private PlayerArea? currentAudioSource;
private readonly Room room;
private readonly MultiplayerRoomUser[] users;
/// <summary>
/// Creates a new <see cref="MultiSpectatorScreen"/>.
@@ -68,9 +70,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
: base(users.Select(u => u.UserID).ToArray())
{
this.room = room;
this.users = users;
instances = new PlayerArea[Users.Count];
leaderboardProvider = new MultiSpectatorLeaderboardProvider(users);
}
[BackgroundDependencyLoader]
@@ -133,25 +135,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
for (int i = 0; i < Users.Count; i++)
grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock()));
LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users)
{
Expanded = { Value = true },
}, _ =>
LoadComponentAsync(leaderboardProvider, _ =>
{
AddInternal(leaderboardProvider);
foreach (var instance in instances)
leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock);
leaderboardProvider.AddClock(instance.UserId, instance.SpectatorPlayerClock);
leaderboardFlow.Insert(0, leaderboard);
if (leaderboard.TeamScores.Count == 2)
if (leaderboardProvider.TeamScores.Count == 2)
{
LoadComponentAsync(new MatchScoreDisplay
{
Team1Score = { BindTarget = leaderboard.TeamScores.First().Value },
Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value },
Team1Score = { BindTarget = leaderboardProvider.TeamScores.First().Value },
Team2Score = { BindTarget = leaderboardProvider.TeamScores.Last().Value },
}, scoreDisplayContainer.Add);
}
});
leaderboardFlow.Insert(0, new DrawableGameplayLeaderboard
{
Expanded = { Value = true }
});
LoadComponentAsync(new GameplayChatDisplay(room)
{
@@ -3,40 +3,48 @@
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Users;
using osu.Game.Screens.Select.Leaderboards;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
{
public abstract partial class GameplayLeaderboard : CompositeDrawable
public partial class DrawableGameplayLeaderboard : CompositeDrawable
{
private readonly Cached sorting = new Cached();
public Bindable<bool> Expanded = new Bindable<bool>();
protected readonly FillFlowContainer<GameplayLeaderboardScore> Flow;
protected readonly FillFlowContainer<DrawableGameplayLeaderboardScore> Flow;
private bool requiresScroll;
private readonly OsuScrollContainer scroll;
public GameplayLeaderboardScore? TrackedScore { get; private set; }
public DrawableGameplayLeaderboardScore? TrackedScore { get; private set; }
[Resolved]
private IGameplayLeaderboardProvider? leaderboardProvider { get; set; }
private readonly IBindableList<GameplayLeaderboardScore> scores = new BindableList<GameplayLeaderboardScore>();
private readonly Bindable<bool> configVisibility = new Bindable<bool>();
private const int max_panels = 8;
/// <summary>
/// Create a new leaderboard.
/// </summary>
protected GameplayLeaderboard()
public DrawableGameplayLeaderboard()
{
Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH;
Width = DrawableGameplayLeaderboardScore.EXTENDED_WIDTH + DrawableGameplayLeaderboardScore.SHEAR_WIDTH;
InternalChildren = new Drawable[]
{
@@ -44,10 +52,10 @@ namespace osu.Game.Screens.Play.HUD
{
ClampExtension = 0,
RelativeSizeAxes = Axes.Both,
Child = Flow = new FillFlowContainer<GameplayLeaderboardScore>
Child = Flow = new FillFlowContainer<DrawableGameplayLeaderboardScore>
{
RelativeSizeAxes = Axes.X,
X = GameplayLeaderboardScore.SHEAR_WIDTH,
X = DrawableGameplayLeaderboardScore.SHEAR_WIDTH,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(2.5f),
@@ -58,26 +66,39 @@ namespace osu.Game.Screens.Play.HUD
};
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility);
}
protected override void LoadComplete()
{
base.LoadComplete();
if (leaderboardProvider != null)
{
scores.BindTo(leaderboardProvider.Scores);
scores.BindCollectionChanged((_, _) =>
{
Clear();
foreach (var score in scores)
Add(score);
}, true);
}
Scheduler.AddDelayed(sort, 1000, true);
configVisibility.BindValueChanged(_ => this.FadeTo(configVisibility.Value ? 1 : 0, 100, Easing.OutQuint), true);
}
/// <summary>
/// Adds a player to the leaderboard.
/// </summary>
/// <param name="user">The player.</param>
/// <param name="isTracked">
/// Whether the player should be tracked on the leaderboard.
/// Set to <c>true</c> for the local player or a player whose replay is currently being played.
/// </param>
public ILeaderboardScore Add(IUser? user, bool isTracked)
public void Add(GameplayLeaderboardScore score)
{
var drawable = CreateLeaderboardScoreDrawable(user, isTracked);
var drawable = CreateLeaderboardScoreDrawable(score);
if (isTracked)
if (score.Tracked)
{
if (TrackedScore != null)
throw new InvalidOperationException("Cannot track more than one score.");
@@ -92,10 +113,8 @@ namespace osu.Game.Screens.Play.HUD
drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true);
int displayCount = Math.Min(Flow.Count, max_panels);
Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y);
Height = displayCount * (DrawableGameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y);
requiresScroll = displayCount != Flow.Count;
return drawable;
}
public void Clear()
@@ -105,8 +124,8 @@ namespace osu.Game.Screens.Play.HUD
scroll.ScrollToStart(false);
}
protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) =>
new GameplayLeaderboardScore(user, isTracked);
protected virtual DrawableGameplayLeaderboardScore CreateLeaderboardScoreDrawable(GameplayLeaderboardScore score) =>
new DrawableGameplayLeaderboardScore(score);
protected override void Update()
{
@@ -119,7 +138,7 @@ namespace osu.Game.Screens.Play.HUD
scroll.ScrollTo(scrollTarget);
}
const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT;
const float panel_height = DrawableGameplayLeaderboardScore.PANEL_HEIGHT;
float fadeBottom = (float)(scroll.Current + scroll.DrawHeight);
float fadeTop = (float)(scroll.Current + panel_height);
@@ -170,15 +189,14 @@ namespace osu.Game.Screens.Play.HUD
for (int i = 0; i < Flow.Count; i++)
{
Flow.SetLayoutPosition(orderedByScore[i], i);
orderedByScore[i].ScorePosition = CheckValidScorePosition(orderedByScore[i], i + 1) ? i + 1 : null;
var score = orderedByScore[i];
Flow.SetLayoutPosition(score, i);
score.ScorePosition = i + 1 == Flow.Count && leaderboardProvider?.IsPartial == true && score.Tracked ? null : i + 1;
}
sorting.Validate();
}
protected virtual bool CheckValidScorePosition(GameplayLeaderboardScore score, int position) => true;
private partial class InputDisabledScrollContainer : OsuScrollContainer
{
public InputDisabledScrollContainer()
@@ -14,6 +14,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Users;
using osu.Game.Users.Drawables;
using osu.Game.Utils;
@@ -22,7 +23,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
{
public partial class GameplayLeaderboardScore : CompositeDrawable, ILeaderboardScore
public partial class DrawableGameplayLeaderboardScore : CompositeDrawable
{
public const float EXTENDED_WIDTH = regular_width + top_player_left_width_extension;
@@ -112,19 +113,27 @@ namespace osu.Game.Screens.Play.HUD
private bool isFriend;
/// <summary>
/// Creates a new <see cref="GameplayLeaderboardScore"/>.
/// Creates a new <see cref="DrawableGameplayLeaderboardScore"/>.
/// </summary>
/// <param name="user">The score's player.</param>
/// <param name="tracked">Whether the player is the local user or a replay player.</param>
public GameplayLeaderboardScore(IUser? user, bool tracked)
public DrawableGameplayLeaderboardScore(GameplayLeaderboardScore score)
{
User = user;
Tracked = tracked;
User = score.User;
Tracked = score.Tracked;
TotalScore.BindTo(score.TotalScore);
Accuracy.BindTo(score.Accuracy);
Combo.BindTo(score.Combo);
HasQuit.BindTo(score.HasQuit);
DisplayOrder.BindTo(score.DisplayOrder);
GetDisplayScore = score.GetDisplayScore;
if (score.TeamColour != null)
{
BackgroundColour = score.TeamColour.Value;
TextColour = Color4.White;
}
AutoSizeAxes = Axes.X;
Height = PANEL_HEIGHT;
GetDisplayScore = _ => TotalScore.Value;
}
[BackgroundDependencyLoader]
@@ -1,31 +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.
using System;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Screens.Play.HUD
{
public interface ILeaderboardScore
{
BindableLong TotalScore { get; }
BindableDouble Accuracy { get; }
BindableInt Combo { get; }
BindableBool HasQuit { get; }
/// <summary>
/// An optional value to guarantee stable ordering.
/// Lower numbers will appear higher in cases of <see cref="TotalScore"/> ties.
/// </summary>
Bindable<long> DisplayOrder { get; }
/// <summary>
/// A custom function which handles converting a score to a display score using a provide <see cref="ScoringMode"/>.
/// </summary>
/// <remarks>
/// If no function is provided, <see cref="TotalScore"/> will be used verbatim.</remarks>
Func<ScoringMode, long> GetDisplayScore { set; }
}
}
@@ -1,108 +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.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Configuration;
using osu.Game.Online.API.Requests;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Select;
using osu.Game.Users;
namespace osu.Game.Screens.Play.HUD
{
public partial class SoloGameplayLeaderboard : GameplayLeaderboard
{
private const int duration = 100;
private readonly Bindable<bool> configVisibility = new Bindable<bool>();
private readonly Bindable<PlayBeatmapDetailArea.TabType> scoreSource = new Bindable<PlayBeatmapDetailArea.TabType>();
private readonly IUser trackingUser;
public readonly IBindableList<ScoreInfo> Scores = new BindableList<ScoreInfo>();
[Resolved]
private ScoreProcessor scoreProcessor { get; set; } = null!;
/// <summary>
/// Whether the leaderboard should be visible regardless of the configuration value.
/// This is true by default, but can be changed.
/// </summary>
public readonly Bindable<bool> AlwaysVisible = new Bindable<bool>(true);
public SoloGameplayLeaderboard(IUser trackingUser)
{
this.trackingUser = trackingUser;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility);
config.BindWith(OsuSetting.BeatmapDetailTab, scoreSource);
}
protected override void LoadComplete()
{
base.LoadComplete();
Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true);
// Alpha will be updated via `updateVisibility` below.
Alpha = 0;
AlwaysVisible.BindValueChanged(_ => updateVisibility());
configVisibility.BindValueChanged(_ => updateVisibility(), true);
}
private void showScores()
{
Clear();
if (!Scores.Any())
return;
foreach (var s in Scores)
{
var score = Add(s.User, false);
score.GetDisplayScore = s.GetDisplayScore;
score.TotalScore.Value = s.TotalScore;
score.Accuracy.Value = s.Accuracy;
score.Combo.Value = s.MaxCombo;
score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds();
}
ILeaderboardScore local = Add(trackingUser, true);
local.GetDisplayScore = scoreProcessor.GetDisplayScore;
local.TotalScore.BindTarget = scoreProcessor.TotalScore;
local.Accuracy.BindTarget = scoreProcessor.Accuracy;
local.Combo.BindTarget = scoreProcessor.HighestCombo;
// Local score should always show lower than any existing scores in cases of ties.
local.DisplayOrder.Value = long.MaxValue;
}
protected override bool CheckValidScorePosition(GameplayLeaderboardScore score, int position)
{
// change displayed position to '-' when there are 50 already submitted scores and tracked score is last
if (score.Tracked && scoreSource.Value != PlayBeatmapDetailArea.TabType.Local)
{
if (position == Flow.Count && Flow.Count > GetScoresRequest.MAX_SCORES_PER_REQUEST)
return false;
}
return base.CheckValidScorePosition(score, position);
}
private void updateVisibility() =>
this.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration);
}
}
+12 -16
View File
@@ -935,34 +935,30 @@ namespace osu.Game.Screens.Play
#region Gameplay leaderboard
protected virtual bool ShowLeaderboard => false;
protected readonly Bindable<bool> LeaderboardExpandedState = new BindableBool();
private void loadLeaderboard()
{
if (!ShowLeaderboard)
return;
HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState());
LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true);
var gameplayLeaderboard = CreateGameplayLeaderboard();
if (gameplayLeaderboard != null)
var gameplayLeaderboard = new DrawableGameplayLeaderboard();
LoadComponentAsync(gameplayLeaderboard, leaderboard =>
{
LoadComponentAsync(gameplayLeaderboard, leaderboard =>
{
if (!LoadedBeatmapSuccessfully)
return;
if (!LoadedBeatmapSuccessfully)
return;
leaderboard.Expanded.BindTo(LeaderboardExpandedState);
leaderboard.Expanded.BindTo(LeaderboardExpandedState);
AddLeaderboardToHUD(leaderboard);
});
}
HUDOverlay.LeaderboardFlow.Add(leaderboard);
});
}
[CanBeNull]
protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null;
protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard);
private void updateLeaderboardExpandedState() =>
LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value;
+8 -30
View File
@@ -8,18 +8,16 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Input.Bindings;
using osu.Game.Online.Leaderboards;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Users;
namespace osu.Game.Screens.Play
@@ -35,6 +33,9 @@ namespace osu.Game.Screens.Play
private PlaybackSettings playbackSettings;
[Cached(typeof(IGameplayLeaderboardProvider))]
private readonly SoloGameplayLeaderboardProvider leaderboardProvider = new SoloGameplayLeaderboardProvider();
protected override UserActivity InitialActivity => new UserActivity.WatchingReplay(Score.ScoreInfo);
private bool isAutoplayPlayback => GameplayState.Mods.OfType<ModAutoplay>().Any();
@@ -48,6 +49,8 @@ namespace osu.Game.Screens.Play
return base.CheckModsAllowFailure();
}
protected override bool ShowLeaderboard => true;
public ReplayPlayer(Score score, PlayerConfiguration configuration = null)
: this((_, _) => score, configuration)
{
@@ -60,12 +63,6 @@ namespace osu.Game.Screens.Play
this.createScore = createScore;
}
[Resolved]
private LeaderboardManager leaderboardManager { get; set; } = null!;
private readonly IBindable<LeaderboardScores> globalScores = new Bindable<LeaderboardScores>();
private readonly BindableList<ScoreInfo> localScores = new BindableList<ScoreInfo>();
/// <summary>
/// Add a settings group to the HUD overlay. Intended to be used by rulesets to add replay-specific settings.
/// </summary>
@@ -82,6 +79,8 @@ namespace osu.Game.Screens.Play
if (!LoadedBeatmapSuccessfully)
return;
AddInternal(leaderboardProvider);
playbackSettings = new PlaybackSettings
{
Depth = float.MaxValue,
@@ -94,20 +93,6 @@ namespace osu.Game.Screens.Play
HUDOverlay.PlayerSettingsOverlay.AddAtStart(playbackSettings);
}
protected override void LoadComplete()
{
base.LoadComplete();
globalScores.BindTo(leaderboardManager.Scores);
globalScores.BindValueChanged(_ =>
{
localScores.Clear();
if (globalScores.Value is LeaderboardScores g)
localScores.AddRange(g.AllScores.OrderByTotalScore());
}, true);
}
protected override void PrepareReplay()
{
DrawableRuleset?.SetReplayScore(Score);
@@ -118,13 +103,6 @@ namespace osu.Game.Screens.Play
// Don't re-import replay scores as they're already present in the database.
protected override Task ImportScore(Score score) => Task.CompletedTask;
protected override GameplayLeaderboard CreateGameplayLeaderboard() =>
new SoloGameplayLeaderboard(Score.ScoreInfo.User)
{
AlwaysVisible = { Value = true },
Scores = { BindTarget = localScores }
};
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score)
{
// Only show the relevant button otherwise things look silly.
+12 -45
View File
@@ -5,50 +5,34 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Leaderboards;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Select.Leaderboards;
namespace osu.Game.Screens.Play
{
public partial class SoloPlayer : SubmittingPlayer
{
public SoloPlayer()
: this(null)
{
}
protected override bool ShowLeaderboard => true;
protected SoloPlayer(PlayerConfiguration configuration = null)
[Cached(typeof(IGameplayLeaderboardProvider))]
private readonly SoloGameplayLeaderboardProvider leaderboardProvider = new SoloGameplayLeaderboardProvider();
public SoloPlayer([CanBeNull] PlayerConfiguration configuration = null)
: base(configuration)
{
}
[Resolved]
private LeaderboardManager leaderboardManager { get; set; } = null!;
private readonly IBindable<LeaderboardScores> globalScores = new Bindable<LeaderboardScores>();
private readonly BindableList<ScoreInfo> localScores = new BindableList<ScoreInfo>();
protected override void LoadComplete()
[BackgroundDependencyLoader]
private void load()
{
base.LoadComplete();
globalScores.BindTo(leaderboardManager.Scores);
globalScores.BindValueChanged(_ =>
{
localScores.Clear();
if (globalScores.Value is LeaderboardScores g)
localScores.AddRange(g.AllScores.OrderByTotalScore());
}, true);
AddInternal(leaderboardProvider);
}
protected override APIRequest<APIScoreToken> CreateTokenRequest()
@@ -65,30 +49,13 @@ namespace osu.Game.Screens.Play
return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash);
}
protected override GameplayLeaderboard CreateGameplayLeaderboard() =>
new SoloGameplayLeaderboard(Score.ScoreInfo.User)
{
AlwaysVisible = { Value = false },
Scores = { BindTarget = localScores }
};
protected override bool ShouldExitOnTokenRetrievalFailure(Exception exception) => false;
protected override Task ImportScore(Score score)
{
// Before importing a score, stop binding the leaderboard with its score source.
// This avoids a case where the imported score may cause a leaderboard refresh
// (if the leaderboard's source is local).
globalScores.UnbindBindings();
return base.ImportScore(score);
}
protected override APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token)
{
IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo;
IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo!;
Debug.Assert(beatmap!.OnlineID > 0);
Debug.Assert(beatmap.OnlineID > 0);
return new SubmitSoloScoreRequest(score.ScoreInfo, token, beatmap.OnlineID);
}
@@ -8,7 +8,6 @@ using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Leaderboards;
using osu.Game.Rulesets;
@@ -71,9 +70,6 @@ namespace osu.Game.Screens.Select.Leaderboards
[Resolved]
private IBindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private LeaderboardManager leaderboardManager { get; set; } = null!;
@@ -94,44 +90,7 @@ namespace osu.Game.Screens.Select.Leaderboards
protected override APIRequest? FetchScores(CancellationToken cancellationToken)
{
var fetchBeatmapInfo = BeatmapInfo;
if (fetchBeatmapInfo == null)
{
SetErrorState(LeaderboardState.NoneSelected);
return null;
}
var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset;
if (!api.IsLoggedIn && IsOnlineScope)
{
SetErrorState(LeaderboardState.NotLoggedIn);
return null;
}
if (!fetchRuleset.IsLegacyRuleset())
{
SetErrorState(LeaderboardState.RulesetUnavailable);
return null;
}
if ((fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) && IsOnlineScope)
{
SetErrorState(LeaderboardState.BeatmapUnavailable);
return null;
}
if (Scope.RequiresSupporter(filterMods) && !api.LocalUser.Value.IsSupporter)
{
SetErrorState(LeaderboardState.NotSupporter);
return null;
}
if (Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null)
{
SetErrorState(LeaderboardState.NoTeam);
return null;
}
var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo?.Ruleset;
leaderboardManager.FetchWithCriteriaAsync(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null))
.ContinueWith(t =>
@@ -145,8 +104,12 @@ namespace osu.Game.Screens.Select.Leaderboards
fetchedScores.UnbindEvents();
fetchedScores.BindValueChanged(scores =>
{
if (scores.NewValue != null)
if (scores.NewValue == null) return;
if (scores.NewValue.FailState == null)
Schedule(() => SetScores(scores.NewValue.TopScores, scores.NewValue.UserScore));
else
Schedule(() => SetErrorState((LeaderboardState)scores.NewValue.FailState));
}, true);
}, cancellationToken);
@@ -0,0 +1,113 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Users;
namespace osu.Game.Screens.Select.Leaderboards
{
/// <summary>
/// Represents a score shown on a gameplay leaderboard.
/// The score is expected to update itself as gameplay progresses.
/// </summary>
public class GameplayLeaderboardScore
{
/// <summary>
/// The user playing.
/// </summary>
public IUser User { get; }
/// <summary>
/// Whether the score is being tracked.
/// Generally understood as true when this score is the score of the local user currently playing.
/// </summary>
public bool Tracked { get; }
/// <summary>
/// The current total of the score.
/// </summary>
public BindableLong TotalScore { get; } = new BindableLong();
/// <summary>
/// The current accuracy of the score.
/// </summary>
public BindableDouble Accuracy { get; } = new BindableDouble();
/// <summary>
/// The current combo of the score.
/// </summary>
public BindableInt Combo { get; } = new BindableInt();
/// <summary>
/// Whether the user playing has quit.
/// </summary>
public BindableBool HasQuit { get; } = new BindableBool();
/// <summary>
/// An optional value to guarantee stable ordering.
/// Lower numbers will appear higher in cases of <see cref="TotalScore"/> ties.
/// </summary>
public Bindable<long> DisplayOrder { get; } = new BindableLong();
/// <summary>
/// A custom function which handles converting a score to a display score using a provided <see cref="ScoringMode"/>.
/// </summary>
/// <remarks>
/// If no function is provided, <see cref="TotalScore"/> will be used verbatim.
/// </remarks>
public Func<ScoringMode, long> GetDisplayScore { get; set; }
/// <summary>
/// The colour of the team that the user playing is on, if any.
/// </summary>
public Colour4? TeamColour { get; init; }
public GameplayLeaderboardScore(IUser user, ScoreProcessor scoreProcessor, bool tracked)
{
User = user;
Tracked = tracked;
TotalScore.BindTarget = scoreProcessor.TotalScore;
Accuracy.BindTarget = scoreProcessor.Accuracy;
Combo.BindTarget = scoreProcessor.Combo;
GetDisplayScore = scoreProcessor.GetDisplayScore;
}
public GameplayLeaderboardScore(IUser user, SpectatorScoreProcessor scoreProcessor, bool tracked)
{
User = user;
Tracked = tracked;
TotalScore.BindTarget = scoreProcessor.TotalScore;
Accuracy.BindTarget = scoreProcessor.Accuracy;
Combo.BindTarget = scoreProcessor.Combo;
GetDisplayScore = scoreProcessor.GetDisplayScore;
}
public GameplayLeaderboardScore(ScoreInfo scoreInfo, bool tracked)
{
User = scoreInfo.User;
Tracked = tracked;
TotalScore.Value = scoreInfo.TotalScore;
Accuracy.Value = scoreInfo.Accuracy;
Combo.Value = scoreInfo.Combo;
DisplayOrder.Value = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds();
GetDisplayScore = scoreInfo.GetDisplayScore;
}
/// <remarks>
/// Used for testing.
/// </remarks>
internal GameplayLeaderboardScore(IUser user, bool tracked, Bindable<long> displayScore)
{
User = user;
Tracked = tracked;
TotalScore.BindTarget = displayScore;
GetDisplayScore = _ => displayScore.Value;
}
}
}
@@ -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.Bindables;
namespace osu.Game.Screens.Select.Leaderboards
{
/// <summary>
/// Provides a leaderboard to show during gameplay.
/// </summary>
public interface IGameplayLeaderboardProvider
{
/// <summary>
/// List of all scores to display on the leaderboard.
/// </summary>
public IBindableList<GameplayLeaderboardScore> Scores { get; }
/// <summary>
/// Whether this leaderboard is a partial leaderboard (e.g. contains only the top 50 of all scores),
/// or is a full leaderboard (contains all scores that there will ever be).
/// </summary>
/// <remarks>
/// If this is <see langword="true"/> and a tracked score is last on the leaderboard, it will show an "unknown" score position.
/// </remarks>
bool IsPartial { get; }
}
}
@@ -4,13 +4,12 @@
using System;
using osu.Framework.Timing;
using osu.Game.Online.Multiplayer;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
namespace osu.Game.Screens.Select.Leaderboards
{
public partial class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard
public partial class MultiSpectatorLeaderboardProvider : MultiplayerLeaderboardProvider
{
public MultiSpectatorLeaderboard(MultiplayerRoomUser[] users)
public MultiSpectatorLeaderboardProvider(MultiplayerRoomUser[] users)
: base(users)
{
}
@@ -11,6 +11,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics.Containers;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
@@ -20,20 +21,30 @@ using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Users;
using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
namespace osu.Game.Screens.Select.Leaderboards
{
[LongRunningLoad]
public partial class MultiplayerGameplayLeaderboard : GameplayLeaderboard
public partial class MultiplayerLeaderboardProvider : CompositeComponent, IGameplayLeaderboardProvider
{
protected readonly Dictionary<int, TrackedUserData> UserScores = new Dictionary<int, TrackedUserData>();
public IBindableList<GameplayLeaderboardScore> Scores => scores;
private readonly BindableList<GameplayLeaderboardScore> scores = new BindableList<GameplayLeaderboardScore>();
protected readonly Dictionary<int, TrackedUserData> UserScores = new Dictionary<int, TrackedUserData>();
public readonly SortedDictionary<int, BindableLong> TeamScores = new SortedDictionary<int, BindableLong>();
public bool HasTeams => TeamScores.Count > 0;
public bool IsPartial => false;
private readonly MultiplayerRoomUser[] users;
private readonly Bindable<ScoringMode> scoringMode = new Bindable<ScoringMode>();
private readonly IBindableList<int> playingUserIds = new BindableList<int>();
[Resolved]
private OsuColour colours { get; set; } = null!;
private UserLookupCache userLookupCache { get; set; } = null!;
[Resolved]
private SpectatorClient spectatorClient { get; set; } = null!;
@@ -42,31 +53,19 @@ namespace osu.Game.Screens.Play.HUD
private MultiplayerClient multiplayerClient { get; set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
private OsuColour colours { get; set; } = null!;
private Bindable<ScoringMode> scoringMode = null!;
private readonly MultiplayerRoomUser[] playingUsers;
private readonly IBindableList<int> playingUserIds = new BindableList<int>();
private bool hasTeams => TeamScores.Count > 0;
/// <summary>
/// Construct a new leaderboard.
/// </summary>
/// <param name="users">IDs of all users in this match.</param>
public MultiplayerGameplayLeaderboard(MultiplayerRoomUser[] users)
public MultiplayerLeaderboardProvider(MultiplayerRoomUser[] users)
{
playingUsers = users;
this.users = users;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config, IAPIProvider api, CancellationToken cancellationToken)
{
scoringMode = config.GetBindable<ScoringMode>(OsuSetting.ScoreDisplayMode);
config.BindWith(OsuSetting.ScoreDisplayMode, scoringMode);
foreach (var user in playingUsers)
foreach (var user in users)
{
var scoreProcessor = new SpectatorScoreProcessor(user.UserID);
scoreProcessor.Mode.BindTo(scoringMode);
@@ -80,29 +79,29 @@ namespace osu.Game.Screens.Play.HUD
TeamScores.Add(team, new BindableLong());
}
userLookupCache.GetUsersAsync(playingUsers.Select(u => u.UserID).ToArray(), cancellationToken)
userLookupCache.GetUsersAsync(users.Select(u => u.UserID).ToArray(), cancellationToken)
.ContinueWith(task =>
{
Schedule(() =>
{
var users = task.GetResultSafely();
var lookedUpUsers = task.GetResultSafely();
for (int i = 0; i < users.Length; i++)
for (int i = 0; i < lookedUpUsers.Length; i++)
{
var user = users[i] ?? new APIUser
var user = lookedUpUsers[i] ?? new APIUser
{
Id = playingUsers[i].UserID,
Id = users[i].UserID,
Username = "Unknown user",
};
var trackedUser = UserScores[user.Id];
var leaderboardScore = Add(user, user.Id == api.LocalUser.Value.Id);
leaderboardScore.GetDisplayScore = trackedUser.ScoreProcessor.GetDisplayScore;
leaderboardScore.Accuracy.BindTo(trackedUser.ScoreProcessor.Accuracy);
leaderboardScore.TotalScore.BindTo(trackedUser.ScoreProcessor.TotalScore);
leaderboardScore.Combo.BindTo(trackedUser.ScoreProcessor.Combo);
leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit);
var leaderboardScore = new GameplayLeaderboardScore(user, trackedUser.ScoreProcessor, user.Id == api.LocalUser.Value.Id)
{
HasQuit = { BindTarget = trackedUser.UserQuit },
TeamColour = UserScores[user.OnlineID].Team is int team ? getTeamColour(team) : null,
};
scores.Add(leaderboardScore);
}
});
}, cancellationToken);
@@ -113,7 +112,7 @@ namespace osu.Game.Screens.Play.HUD
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)
foreach (var user in users)
{
spectatorClient.WatchUser(user.UserID);
@@ -127,34 +126,6 @@ namespace osu.Game.Screens.Play.HUD
playingUserIds.BindCollectionChanged(playingUsersChanged);
}
protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked)
{
var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked);
if (user != null)
{
if (UserScores[user.OnlineID].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 playingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
@@ -176,10 +147,10 @@ namespace osu.Game.Screens.Play.HUD
private void updateTotals()
{
if (!hasTeams)
if (!HasTeams)
return;
foreach (var scores in TeamScores.Values) scores.Value = 0;
foreach (var teamTotal in TeamScores.Values) teamTotal.Value = 0;
foreach (var u in UserScores.Values)
{
@@ -191,13 +162,25 @@ namespace osu.Game.Screens.Play.HUD
}
}
private Color4 getTeamColour(int team)
{
switch (team)
{
case 0:
return colours.TeamColourRed.Lighten(1.2f);
default:
return colours.TeamColourBlue.Lighten(1.2f);
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (spectatorClient.IsNotNull())
{
foreach (var user in playingUsers)
foreach (var user in users)
spectatorClient.StopWatchingUser(user.UserID);
}
}
@@ -0,0 +1,50 @@
// 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.Graphics;
using osu.Game.Online.Leaderboards;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.Select.Leaderboards
{
public partial class SoloGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider
{
public bool IsPartial { get; private set; }
public IBindableList<GameplayLeaderboardScore> Scores => scores;
private readonly BindableList<GameplayLeaderboardScore> scores = new BindableList<GameplayLeaderboardScore>();
[Resolved]
private LeaderboardManager? leaderboardManager { get; set; }
[Resolved]
private GameplayState? gameplayState { get; set; }
protected override void LoadComplete()
{
base.LoadComplete();
var globalScores = leaderboardManager?.Scores.Value;
IsPartial = leaderboardManager?.CurrentCriteria?.Scope != BeatmapLeaderboardScope.Local && globalScores?.TopScores.Count >= 50;
if (globalScores != null)
{
foreach (var topScore in globalScores.AllScores.OrderByTotalScore())
scores.Add(new GameplayLeaderboardScore(topScore, false));
}
if (gameplayState != null)
{
scores.Add(new GameplayLeaderboardScore(gameplayState.Score.ScoreInfo.User, gameplayState.ScoreProcessor, true)
{
// Local score should always show lower than any existing scores in cases of ties.
DisplayOrder = { Value = long.MaxValue }
});
}
}
}
}
@@ -47,7 +47,9 @@ namespace osu.Game.Screens.SelectV2
var newItems = new List<CarouselItem>();
BeatmapInfo? lastBeatmap = null;
GroupDefinition? lastGroup = null;
CarouselItem? lastGroupItem = null;
HashSet<CarouselItem>? currentGroupItems = null;
HashSet<CarouselItem>? currentSetItems = null;
@@ -69,7 +71,7 @@ namespace osu.Game.Screens.SelectV2
groupItems[newGroup] = currentGroupItems = new HashSet<CarouselItem>();
lastGroup = newGroup;
addItem(new CarouselItem(newGroup)
addItem(lastGroupItem = new CarouselItem(newGroup)
{
DrawHeight = PanelGroup.HEIGHT,
DepthLayer = -2,
@@ -84,6 +86,9 @@ namespace osu.Game.Screens.SelectV2
{
setItems[beatmap.BeatmapSet!] = currentSetItems = new HashSet<CarouselItem>();
if (lastGroupItem != null)
lastGroupItem.NestedItemCount++;
addItem(new CarouselItem(beatmap.BeatmapSet!)
{
DrawHeight = PanelBeatmapSet.HEIGHT,
@@ -91,6 +96,11 @@ namespace osu.Game.Screens.SelectV2
});
}
}
else
{
if (lastGroupItem != null)
lastGroupItem.NestedItemCount++;
}
addItem(item);
lastBeatmap = beatmap;
@@ -0,0 +1,271 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osuTK;
using Realms;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// A dropdown to select the collection to be used to filter results.
/// </summary>
public partial class CollectionDropdown : ShearedDropdown<CollectionFilterMenuItem> // TODO: partial class under FilterControl?
{
/// <summary>
/// Whether to show the "manage collections..." menu item in the dropdown.
/// </summary>
protected virtual bool ShowManageCollectionsItem => true;
public Action? RequestFilter { private get; set; }
private readonly BindableList<CollectionFilterMenuItem> filters = new BindableList<CollectionFilterMenuItem>();
[Resolved]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
[Resolved]
private RealmAccess realm { get; set; } = null!;
private IDisposable? realmSubscription;
private readonly CollectionFilterMenuItem allBeatmapsItem = new AllBeatmapsCollectionFilterMenuItem();
public CollectionDropdown()
: base("Collection")
{
ItemSource = filters;
Current.Value = allBeatmapsItem;
AlwaysShowSearchBar = true;
}
protected override void LoadComplete()
{
base.LoadComplete();
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>().OrderBy(c => c.Name), collectionsChanged);
Current.BindValueChanged(selectionChanged);
}
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes)
{
if (changes == null)
{
filters.Clear();
filters.Add(allBeatmapsItem);
filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm))));
if (ShowManageCollectionsItem)
filters.Add(new ManageCollectionsFilterMenuItem());
}
else
{
foreach (int i in changes.DeletedIndices.OrderDescending())
filters.RemoveAt(i + 1);
foreach (int i in changes.InsertedIndices)
filters.Insert(i + 1, new CollectionFilterMenuItem(collections[i].ToLive(realm)));
var selectedItem = SelectedItem?.Value;
foreach (int i in changes.NewModifiedIndices)
{
var updatedItem = collections[i];
// This is responsible for updating the state of the +/- button and the collection's name.
// TODO: we can probably make the menu items update with changes to avoid this.
filters.RemoveAt(i + 1);
filters.Insert(i + 1, new CollectionFilterMenuItem(updatedItem.ToLive(realm)));
if (updatedItem.ID == selectedItem?.Collection?.ID)
{
// This current update and schedule is required to work around dropdown headers not updating text even when the selected item
// changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue
// a warning that it's going to be a frustrating journey.
Current.Value = allBeatmapsItem;
Schedule(() =>
{
// current may have changed before the scheduled call is run.
if (Current.Value != allBeatmapsItem)
return;
Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0];
});
// Trigger an external re-filter if the current item was in the change set.
RequestFilter?.Invoke();
break;
}
}
}
}
private Live<BeatmapCollection>? lastFiltered;
private void selectionChanged(ValueChangedEvent<CollectionFilterMenuItem> filter)
{
// May be null during .Clear().
if (filter.NewValue.IsNull())
return;
// Never select the manage collection filter - rollback to the previous filter.
// This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value.
if (filter.NewValue is ManageCollectionsFilterMenuItem)
{
Current.Value = filter.OldValue;
manageCollectionsDialog?.Show();
return;
}
var newCollection = filter.NewValue.Collection;
// This dropdown be weird.
// We only care about filtering if the actual collection has changed.
if (newCollection != lastFiltered)
{
RequestFilter?.Invoke();
lastFiltered = newCollection;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
realmSubscription?.Dispose();
}
protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName;
protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu();
protected virtual ShearedCollectionDropdownMenu CreateCollectionMenu() => new ShearedCollectionDropdownMenu();
protected partial class ShearedCollectionDropdownMenu : ShearedDropdownMenu
{
public ShearedCollectionDropdownMenu()
{
MaxHeight = 200;
}
protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableCollectionMenuItem(item)
{
BackgroundColourHover = HoverColour,
BackgroundColourSelected = SelectionColour
};
}
protected partial class DrawableCollectionMenuItem : ShearedDropdownMenu.ShearedMenuItem
{
private IconButton addOrRemoveButton = null!;
private bool beatmapInCollection;
private readonly Live<BeatmapCollection>? collection;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
public DrawableCollectionMenuItem(MenuItem item)
: base(item)
{
collection = ((DropdownMenuItem<CollectionFilterMenuItem>)item).Value.Collection;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(addOrRemoveButton = new NoFocusChangeIconButton
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Shear = -OsuGame.SHEAR,
X = -OsuScrollContainer.SCROLL_BAR_WIDTH,
Scale = new Vector2(0.65f),
Action = addOrRemove,
});
}
protected override void LoadComplete()
{
base.LoadComplete();
if (collection != null)
{
beatmap.BindValueChanged(_ =>
{
beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash));
addOrRemoveButton.Enabled.Value = !beatmap.IsDefault;
addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare;
addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap";
updateButtonVisibility();
}, true);
}
updateButtonVisibility();
}
protected override bool OnHover(HoverEvent e)
{
updateButtonVisibility();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateButtonVisibility();
base.OnHoverLost(e);
}
protected override void OnSelectChange()
{
base.OnSelectChange();
updateButtonVisibility();
}
private void updateButtonVisibility()
{
if (collection == null)
addOrRemoveButton.Alpha = 0;
else
addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0;
}
private void addOrRemove()
{
Debug.Assert(collection != null);
collection.PerformWrite(c =>
{
if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash))
c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash);
});
}
protected override Drawable CreateContent() => (Content)base.CreateContent();
private partial class NoFocusChangeIconButton : IconButton
{
public override bool ChangeFocusOnClick => false;
}
}
}
}
+7 -6
View File
@@ -73,7 +73,7 @@ namespace osu.Game.Screens.SelectV2
Icon = difficultyIcon = new ConstrainedIconContainer
{
Size = new Vector2(20),
Size = new Vector2(16f),
Margin = new MarginPadding { Horizontal = 5f },
Colour = colourProvider.Background5,
};
@@ -100,12 +100,13 @@ namespace osu.Game.Screens.SelectV2
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.875f),
},
localRank = new PanelLocalRankDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.75f)
Scale = new Vector2(0.65f)
},
starCounter = new StarCounter
{
@@ -123,22 +124,22 @@ namespace osu.Game.Screens.SelectV2
{
keyCountText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold),
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Alpha = 0,
},
difficultyText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold),
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Margin = new MarginPadding { Right = 8f },
Margin = new MarginPadding { Right = 5f },
},
authorText = new OsuSpriteText
{
Colour = colourProvider.Content2,
Font = OsuFont.GetFont(weight: FontWeight.SemiBold),
Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold),
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft
}
+6 -7
View File
@@ -20,7 +20,7 @@ namespace osu.Game.Screens.SelectV2
{
public partial class PanelBeatmapSet : Panel
{
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f;
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.7f;
private PanelSetBackground background = null!;
@@ -55,7 +55,7 @@ namespace osu.Game.Screens.SelectV2
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Icon = FontAwesome.Solid.ChevronRight,
Size = new Vector2(12),
Size = new Vector2(8),
X = 1f,
Colour = colourProvider.Background5,
},
@@ -77,17 +77,17 @@ namespace osu.Game.Screens.SelectV2
{
titleText = new OsuSpriteText
{
Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true),
Font = OsuFont.Style.Heading1.With(typeface: Typeface.TorusAlternate),
},
artistText = new OsuSpriteText
{
Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true),
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
},
new FillFlowContainer
{
Direction = FillDirection.Horizontal,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Top = 5f },
Margin = new MarginPadding { Top = 4f },
Children = new Drawable[]
{
updateButton = new PanelUpdateBeatmapButton
@@ -100,8 +100,7 @@ namespace osu.Game.Screens.SelectV2
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
TextSize = 11,
TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 },
TextSize = OsuFont.Style.Caption2.Size,
Margin = new MarginPadding { Right = 5f },
},
difficultiesDisplay = new DifficultySpectrumDisplay
@@ -26,7 +26,7 @@ namespace osu.Game.Screens.SelectV2
{
public partial class PanelBeatmapStandalone : Panel
{
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f;
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.7f;
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
@@ -76,7 +76,7 @@ namespace osu.Game.Screens.SelectV2
Icon = difficultyIcon = new ConstrainedIconContainer
{
Size = new Vector2(20),
Size = new Vector2(16),
Margin = new MarginPadding { Horizontal = 5f },
Colour = colourProvider.Background5,
};
@@ -95,19 +95,16 @@ namespace osu.Game.Screens.SelectV2
{
titleText = new OsuSpriteText
{
Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true),
Shadow = true,
Font = OsuFont.Style.Heading1.With(typeface: Typeface.TorusAlternate),
},
artistText = new OsuSpriteText
{
Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true),
Shadow = true,
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
},
new FillFlowContainer
{
Direction = FillDirection.Horizontal,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Top = 5f },
Children = new Drawable[]
{
updateButton = new PanelUpdateBeatmapButton
@@ -120,8 +117,7 @@ namespace osu.Game.Screens.SelectV2
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
TextSize = 11,
TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 },
TextSize = OsuFont.Style.Caption2.Size,
Margin = new MarginPadding { Right = 5f },
},
difficultyLine = new FillFlowContainer
@@ -134,19 +130,19 @@ namespace osu.Game.Screens.SelectV2
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Scale = new Vector2(8f / 9f),
Scale = new Vector2(0.875f),
Margin = new MarginPadding { Right = 5f },
},
difficultyRank = new PanelLocalRankDisplay
{
Scale = new Vector2(8f / 11),
Scale = new Vector2(0.65f),
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Margin = new MarginPadding { Right = 5f },
},
difficultyKeyCountText = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold),
Font = OsuFont.Style.Heading2,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Alpha = 0,
@@ -154,7 +150,7 @@ namespace osu.Game.Screens.SelectV2
},
difficultyName = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 18, weight: FontWeight.SemiBold),
Font = OsuFont.Style.Heading2,
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Margin = new MarginPadding { Right = 5f, Bottom = 2f },
@@ -162,7 +158,7 @@ namespace osu.Game.Screens.SelectV2
difficultyAuthor = new OsuSpriteText
{
Colour = colourProvider.Content2,
Font = OsuFont.GetFont(weight: FontWeight.SemiBold),
Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold),
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
Margin = new MarginPadding { Right = 5f, Bottom = 2f },
+3 -3
View File
@@ -26,6 +26,7 @@ namespace osu.Game.Screens.SelectV2
private Drawable iconContainer = null!;
private OsuSpriteText titleText = null!;
private TrianglesV2 triangles = null!;
private OsuSpriteText countText = null!;
private Box glow = null!;
[Resolved]
@@ -99,13 +100,11 @@ namespace osu.Game.Screens.SelectV2
RelativeSizeAxes = Axes.Both,
Colour = Color4.Black.Opacity(0.7f),
},
new OsuSpriteText
countText = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold),
// TODO: requires Carousel/CarouselItem-side implementation
Text = "43",
UseFullGlyphHeight = false,
}
},
@@ -144,6 +143,7 @@ namespace osu.Game.Screens.SelectV2
GroupDefinition group = (GroupDefinition)Item.Model;
titleText.Text = group.Title;
countText.Text = Item.NestedItemCount.ToString("N0");
}
}
}
@@ -55,7 +55,7 @@ namespace osu.Game.Screens.SelectV2
public PanelUpdateBeatmapButton()
{
Size = new Vector2(75f, 22f);
Size = new Vector2(72, 22f);
}
private Bindable<bool> preferNoVideo = null!;
@@ -63,7 +63,7 @@ namespace osu.Game.Screens.SelectV2
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
const float icon_size = 14;
const float icon_size = 12;
preferNoVideo = config.GetBindable<bool>(OsuSetting.PreferNoVideo);
@@ -110,7 +110,7 @@ namespace osu.Game.Screens.SelectV2
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Font = OsuFont.Default.With(weight: FontWeight.Bold),
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
Text = "Update",
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ namespace osu.Game.Storyboards
private readonly List<StoryboardTriggerGroup> triggerGroups = new List<StoryboardTriggerGroup>();
public string Path { get; }
public bool IsDrawable => HasCommands;
public virtual bool IsDrawable => HasCommands;
public Anchor Origin;
public Vector2 InitialPosition;
+2
View File
@@ -19,6 +19,8 @@ namespace osu.Game.Storyboards
public override double StartTime { get; }
public override bool IsDrawable => true;
public override Drawable CreateDrawable() => new DrawableStoryboardVideo(this);
}
}
@@ -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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play;
namespace osu.Game.Tests.Visual
{
/// <summary>
/// The goal of this abstract test class is to exercise correct playback of replays sourced from previous osu! versions.
/// Use <see cref="RunTest"/> to exercise that property.
/// </summary>
[HeadlessTest]
[TestFixture]
public abstract partial class LegacyReplayPlaybackTestScene : RateAdjustedBeatmapTestScene
{
private ReplayPlayer currentPlayer = null!;
private readonly List<JudgementResult> results = new List<JudgementResult>();
/// <summary>
/// This is provided as a convenience for testing behaviour against osu!stable.
/// Setting this field to a non-null path will cause beatmap files and replays used in all test cases
/// to be exported to disk so that they can be cross-checked against stable.
/// </summary>
protected abstract string? ExportLocation { get; }
/// <summary>
/// Encodes the supplied <paramref name="originalScore"/>, decodes the result of encoding, runs the result of decoding against the supplied <paramref name="beatmap"/>,
/// and checks that the judgement results recorded still match <paramref name="expectedResults"/>.
/// If <see cref="ExportLocation"/> is set, exports both the beatmap and the replay to said location.
/// </summary>
protected void RunTest(string beatmapName, IBeatmap beatmap, string replayName, Score originalScore, IEnumerable<HitResult> expectedResults)
{
IBeatmap playableBeatmap = null!;
MemoryStream beatmapStream = new MemoryStream();
MemoryStream scoreStream = new MemoryStream();
Score decodedScore = null!;
AddStep(@"set up beatmap", () =>
{
beatmap.Metadata.Title = beatmapName;
Beatmap.Value = CreateWorkingBeatmap(beatmap);
Ruleset.Value = CreateRuleset()!.RulesetInfo;
playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
var beatmapEncoder = new LegacyBeatmapEncoder(beatmap, null);
using (var writer = new StreamWriter(beatmapStream, Encoding.UTF8, leaveOpen: true))
beatmapEncoder.Encode(writer);
beatmapStream.Seek(0, SeekOrigin.Begin);
playableBeatmap.BeatmapInfo.MD5Hash = beatmapStream.ComputeMD5Hash();
});
AddStep(@"encode score", () =>
{
originalScore.ScoreInfo.BeatmapInfo = playableBeatmap.BeatmapInfo;
var encoder = new LegacyScoreEncoder(originalScore, playableBeatmap);
encoder.Encode(scoreStream, leaveOpen: true);
// `LegacyScoreEncoder` hardcodes a replay version that belongs to lazer.
// here we want to simulate a stable replay, which should have the classic mod attached etc.
// to that end, we do a post-encode step to specify a stable-like replay version.
scoreStream.Position = 1;
using (var sw = new SerializationWriter(scoreStream, leaveOpen: true))
{
const int version = 20250414;
sw.Write(version);
}
scoreStream.Position = 0;
});
if (ExportLocation != null)
{
AddStep("export beatmap", () =>
{
using var stream = File.Open(Path.Combine(ExportLocation, $"{beatmapName}.osu"), FileMode.Create);
beatmapStream.CopyTo(stream);
beatmapStream.Position = 0;
});
AddStep("export score", () =>
{
using var stream = File.Open(Path.Combine(ExportLocation, $@"{replayName}.osr"), FileMode.Create);
scoreStream.CopyTo(stream);
scoreStream.Position = 0;
});
}
AddStep(@"decode score", () =>
{
using (scoreStream)
{
scoreStream.Position = 0;
decodedScore = new TestScoreDecoder(Beatmap.Value, Ruleset.Value).Parse(scoreStream);
}
});
AddAssert(@"classic mod present", () => decodedScore.ScoreInfo.Mods.Any(mod => mod is ModClassic));
AddStep(@"push player", () => pushNewPlayer(decodedScore));
AddUntilStep(@"Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
AddAssert(@"classic mod present", () => currentPlayer.GameplayState.Mods.Any(mod => mod is ModClassic));
AddUntilStep(@"Wait for completion", () => currentPlayer.GameplayState.HasCompleted);
AddAssert(@"judgement results after encode are correct", () => results.Select(r => r.Type), () => Is.EquivalentTo(expectedResults));
}
private void pushNewPlayer(Score score)
{
var player = new ReplayPlayer(score);
SelectedMods.Value = score.ScoreInfo.Mods;
player.OnLoadComplete += _ =>
{
player.GameplayState.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == player)
results.Add(result);
};
};
LoadScreen(currentPlayer = player);
results.Clear();
}
private class TestScoreDecoder : LegacyScoreDecoder
{
private readonly WorkingBeatmap beatmap;
private readonly Ruleset ruleset;
public TestScoreDecoder(WorkingBeatmap beatmap, RulesetInfo ruleset)
{
this.beatmap = beatmap;
this.ruleset = ruleset.CreateInstance();
}
protected override Ruleset GetRuleset(int rulesetId) => ruleset;
protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmap;
}
}
}
+1 -1
View File
@@ -35,7 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.321.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.419.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2025.321.0" />
<PackageReference Include="Sentry" Version="5.1.1" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
+1 -1
View File
@@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.321.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.419.0" />
</ItemGroup>
</Project>