1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-24 03:29:53 +08:00

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

This commit is contained in:
Dean Herbert
2025-04-26 06:49:12 +09:00
Unverified
50 changed files with 1679 additions and 470 deletions
@@ -297,34 +297,202 @@ namespace osu.Game.Rulesets.Mania.Tests
new object[] { 3.1f, -123d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_hard_rock_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 7.
// PERFECT hit window is [-11ms, 11ms]
// GREAT hit window is [-35ms, 35ms]
// GOOD hit window is [-58ms, 58ms]
// OK hit window is [-80ms, 80ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-97ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -10d, HitResult.Perfect },
new object[] { 5f, -11d, HitResult.Perfect },
new object[] { 5f, -12d, HitResult.Great },
new object[] { 5f, -13d, HitResult.Great },
new object[] { 5f, -34d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Great },
new object[] { 5f, -36d, HitResult.Good },
new object[] { 5f, -37d, HitResult.Good },
new object[] { 5f, -57d, HitResult.Good },
new object[] { 5f, -58d, HitResult.Good },
new object[] { 5f, -59d, HitResult.Ok },
new object[] { 5f, -60d, HitResult.Ok },
new object[] { 5f, -79d, HitResult.Ok },
new object[] { 5f, -80d, HitResult.Ok },
new object[] { 5f, -81d, HitResult.Meh },
new object[] { 5f, -82d, HitResult.Meh },
new object[] { 5f, -96d, HitResult.Meh },
new object[] { 5f, -97d, HitResult.Meh },
new object[] { 5f, -98d, HitResult.Miss },
new object[] { 5f, -99d, HitResult.Miss },
new object[] { 5f, 79d, HitResult.Ok },
new object[] { 5f, 80d, HitResult.Miss },
new object[] { 5f, 81d, HitResult.Miss },
new object[] { 5f, 82d, HitResult.Miss },
new object[] { 5f, 96d, HitResult.Miss },
new object[] { 5f, 97d, HitResult.Miss },
new object[] { 5f, 98d, HitResult.Miss },
new object[] { 5f, 99d, HitResult.Miss },
// OD = 9.3 test cases.
// This leads to "effective" OD of 13.02.
// Note that contrary to other rulesets this does NOT cap out to OD 10!
// PERFECT hit window is [-11ms, 11ms]
// GREAT hit window is [-25ms, 25ms]
// GOOD hit window is [-49ms, 49ms]
// OK hit window is [-70ms, 70ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-87ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 9.3f, 10d, HitResult.Perfect },
new object[] { 9.3f, 11d, HitResult.Perfect },
new object[] { 9.3f, 12d, HitResult.Great },
new object[] { 9.3f, 13d, HitResult.Great },
new object[] { 9.3f, 24d, HitResult.Great },
new object[] { 9.3f, 25d, HitResult.Great },
new object[] { 9.3f, 26d, HitResult.Good },
new object[] { 9.3f, 27d, HitResult.Good },
new object[] { 9.3f, 48d, HitResult.Good },
new object[] { 9.3f, 49d, HitResult.Good },
new object[] { 9.3f, 50d, HitResult.Ok },
new object[] { 9.3f, 51d, HitResult.Ok },
new object[] { 9.3f, 69d, HitResult.Ok },
new object[] { 9.3f, 70d, HitResult.Miss },
new object[] { 9.3f, 71d, HitResult.Miss },
new object[] { 9.3f, 72d, HitResult.Miss },
new object[] { 9.3f, 86d, HitResult.Miss },
new object[] { 9.3f, 87d, HitResult.Miss },
new object[] { 9.3f, 88d, HitResult.Miss },
new object[] { 9.3f, 89d, HitResult.Miss },
new object[] { 9.3f, -69d, HitResult.Ok },
new object[] { 9.3f, -70d, HitResult.Ok },
new object[] { 9.3f, -71d, HitResult.Meh },
new object[] { 9.3f, -72d, HitResult.Meh },
new object[] { 9.3f, -86d, HitResult.Meh },
new object[] { 9.3f, -87d, HitResult.Meh },
new object[] { 9.3f, -88d, HitResult.Miss },
new object[] { 9.3f, -89d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_easy_test_cases =
{
// Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic).
// PERFECT hit window is [ -22ms, 22ms]
// GREAT hit window is [ -68ms, 68ms]
// GOOD hit window is [-114ms, 114ms]
// OK hit window is [-156ms, 156ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-190ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -21d, HitResult.Perfect },
new object[] { 5f, -22d, HitResult.Perfect },
new object[] { 5f, -23d, HitResult.Great },
new object[] { 5f, -24d, HitResult.Great },
new object[] { 5f, -67d, HitResult.Great },
new object[] { 5f, -68d, HitResult.Great },
new object[] { 5f, -69d, HitResult.Good },
new object[] { 5f, -70d, HitResult.Good },
new object[] { 5f, -113d, HitResult.Good },
new object[] { 5f, -114d, HitResult.Good },
new object[] { 5f, -115d, HitResult.Ok },
new object[] { 5f, -116d, HitResult.Ok },
new object[] { 5f, -155d, HitResult.Ok },
new object[] { 5f, -156d, HitResult.Ok },
new object[] { 5f, -157d, HitResult.Meh },
new object[] { 5f, -158d, HitResult.Meh },
new object[] { 5f, -189d, HitResult.Meh },
new object[] { 5f, -190d, HitResult.Meh },
new object[] { 5f, -191d, HitResult.Miss },
new object[] { 5f, -192d, HitResult.Miss },
new object[] { 5f, 155d, HitResult.Ok },
new object[] { 5f, 156d, HitResult.Miss },
new object[] { 5f, 157d, HitResult.Miss },
new object[] { 5f, 158d, HitResult.Miss },
new object[] { 5f, 189d, HitResult.Miss },
new object[] { 5f, 190d, HitResult.Miss },
new object[] { 5f, 191d, HitResult.Miss },
new object[] { 5f, 192d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_double_time_test_cases =
{
// Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic).
// PERFECT hit window is [ -24ms, 24ms]
// GREAT hit window is [ -73ms, 73ms]
// GOOD hit window is [-123ms, 123ms]
// OK hit window is [-168ms, 168ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-204ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -23d, HitResult.Perfect },
new object[] { 5f, -24d, HitResult.Perfect },
new object[] { 5f, -25d, HitResult.Great },
new object[] { 5f, -26d, HitResult.Great },
new object[] { 5f, -72d, HitResult.Great },
new object[] { 5f, -73d, HitResult.Great },
new object[] { 5f, -74d, HitResult.Good },
new object[] { 5f, -75d, HitResult.Good },
new object[] { 5f, -122d, HitResult.Good },
new object[] { 5f, -123d, HitResult.Good },
new object[] { 5f, -124d, HitResult.Ok },
new object[] { 5f, -125d, HitResult.Ok },
new object[] { 5f, -167d, HitResult.Ok },
new object[] { 5f, -168d, HitResult.Ok },
new object[] { 5f, -169d, HitResult.Meh },
new object[] { 5f, -170d, HitResult.Meh },
new object[] { 5f, -203d, HitResult.Meh },
new object[] { 5f, -204d, HitResult.Meh },
new object[] { 5f, -205d, HitResult.Miss },
new object[] { 5f, -206d, HitResult.Miss },
new object[] { 5f, 167d, HitResult.Ok },
new object[] { 5f, 168d, HitResult.Miss },
new object[] { 5f, 169d, HitResult.Miss },
new object[] { 5f, 170d, HitResult.Miss },
new object[] { 5f, 203d, HitResult.Miss },
new object[] { 5f, 204d, HitResult.Miss },
new object[] { 5f, 205d, HitResult.Miss },
new object[] { 5f, 206d, HitResult.Miss },
};
private static readonly object[][] score_v1_non_convert_half_time_test_cases =
{
// Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic).
// PERFECT hit window is [ -12ms, 12ms]
// GREAT hit window is [ -36ms, 36ms]
// GOOD hit window is [ -61ms, 61ms]
// OK hit window is [ -84ms, 84ms) <- not a typo, this side of the interval is OPEN!
// MEH hit window is [-102ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit!
new object[] { 5f, -11d, HitResult.Perfect },
new object[] { 5f, -12d, HitResult.Perfect },
new object[] { 5f, -13d, HitResult.Great },
new object[] { 5f, -14d, HitResult.Great },
new object[] { 5f, -35d, HitResult.Great },
new object[] { 5f, -36d, HitResult.Great },
new object[] { 5f, -37d, HitResult.Good },
new object[] { 5f, -38d, HitResult.Good },
new object[] { 5f, -60d, HitResult.Good },
new object[] { 5f, -61d, HitResult.Good },
new object[] { 5f, -62d, HitResult.Ok },
new object[] { 5f, -63d, HitResult.Ok },
new object[] { 5f, -83d, HitResult.Ok },
new object[] { 5f, -84d, HitResult.Ok },
new object[] { 5f, -85d, HitResult.Meh },
new object[] { 5f, -86d, HitResult.Meh },
new object[] { 5f, -101d, HitResult.Meh },
new object[] { 5f, -102d, HitResult.Meh },
new object[] { 5f, -103d, HitResult.Miss },
new object[] { 5f, -104d, HitResult.Miss },
new object[] { 5f, 83d, HitResult.Ok },
new object[] { 5f, 84d, HitResult.Miss },
new object[] { 5f, 85d, HitResult.Miss },
new object[] { 5f, 86d, HitResult.Miss },
new object[] { 5f, 101d, HitResult.Miss },
new object[] { 5f, 102d, HitResult.Miss },
new object[] { 5f, 103d, HitResult.Miss },
new object[] { 5f, 104d, HitResult.Miss },
};
private const double note_time = 300;
[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 beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
@@ -352,31 +520,7 @@ namespace osu.Game.Rulesets.Mania.Tests
[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 beatmap = createNonConvertBeatmap(overallDifficulty);
var replay = new Replay
{
@@ -403,29 +547,7 @@ namespace osu.Game.Rulesets.Mania.Tests
[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 beatmap = createConvertBeatmap(overallDifficulty);
var replay = new Replay
{
@@ -450,6 +572,172 @@ namespace osu.Game.Rulesets.Mania.Tests
RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_hard_rock_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndHardRockNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
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 ManiaModHardRock()],
}
};
RunTest($@"SV1+HR single note @ OD{overallDifficulty}", beatmap, $@"SV1+HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_easy_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndEasyNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
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 ManiaModEasy()],
}
};
RunTest($@"SV1+EZ single note @ OD{overallDifficulty}", beatmap, $@"SV1+EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_double_time_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndDoubleTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
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 ManiaModDoubleTime()],
}
};
RunTest($@"SV1+DT single note @ OD{overallDifficulty}", beatmap, $@"SV1+DT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(score_v1_non_convert_half_time_test_cases))]
public void TestHitWindowTreatmentWithScoreV1AndHalfTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createNonConvertBeatmap(overallDifficulty);
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 ManiaModHalfTime()],
}
};
RunTest($@"SV1+HT single note @ OD{overallDifficulty}", beatmap, $@"SV1+HT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private static ManiaBeatmap createNonConvertBeatmap(float overallDifficulty)
{
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,
};
return beatmap;
}
private static Beatmap createConvertBeatmap(float overallDifficulty)
{
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,
};
return beatmap;
}
private class FakeCircle : HitObject, IHasPosition
{
public float X
@@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Mania.Mods
public override bool Ranked => false;
public override bool ValidForFreestyleAsRequiredMod => false;
[SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")]
public override BindableNumber<float> Coverage { get; } = new BindableFloat(0.5f)
{
@@ -14,6 +14,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "FI";
public override LocalisableString Description => @"Keys appear out of nowhere!";
public override double ScoreMultiplier => 1;
public override bool ValidForFreestyleAsRequiredMod => false;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
@@ -6,6 +6,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
@@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Tests
protected override string? ExportLocation => null;
private static readonly object[][] test_cases =
private static readonly object[][] no_mod_test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
@@ -65,30 +66,73 @@ namespace osu.Game.Rulesets.Osu.Tests
new object[] { 5.7f, 144d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
private static readonly object[][] hard_rock_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 7.
// GREAT hit window is ( -38ms, 38ms)
// OK hit window is ( -84ms, 84ms)
// MEH hit window is (-130ms, 130ms)
new object[] { 5f, 36d, HitResult.Great },
new object[] { 5f, 37d, HitResult.Great },
new object[] { 5f, 38d, HitResult.Ok },
new object[] { 5f, 39d, HitResult.Ok },
new object[] { 5f, 82d, HitResult.Ok },
new object[] { 5f, 83d, HitResult.Ok },
new object[] { 5f, 84d, HitResult.Meh },
new object[] { 5f, 85d, HitResult.Meh },
new object[] { 5f, 128d, HitResult.Meh },
new object[] { 5f, 129d, HitResult.Meh },
new object[] { 5f, 130d, HitResult.Miss },
new object[] { 5f, 131d, HitResult.Miss },
// OD = 8 test cases.
// This would lead to "effective" OD of 11.2,
// but the effects are capped to OD 10.
// GREAT hit window is ( -20ms, 20ms)
// OK hit window is ( -60ms, 60ms)
// MEH hit window is (-100ms, 100ms)
new object[] { 8f, 18d, HitResult.Great },
new object[] { 8f, 19d, HitResult.Great },
new object[] { 8f, 20d, HitResult.Ok },
new object[] { 8f, 21d, HitResult.Ok },
new object[] { 8f, 58d, HitResult.Ok },
new object[] { 8f, 59d, HitResult.Ok },
new object[] { 8f, 60d, HitResult.Meh },
new object[] { 8f, 61d, HitResult.Meh },
new object[] { 8f, 98d, HitResult.Meh },
new object[] { 8f, 99d, HitResult.Meh },
new object[] { 8f, 100d, HitResult.Miss },
new object[] { 8f, 101d, HitResult.Miss },
};
private static readonly object[][] easy_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 2.5.
// GREAT hit window is ( -65ms, 65ms)
// OK hit window is (-120ms, 120ms)
// MEH hit window is (-175ms, 175ms)
new object[] { 5f, 63d, HitResult.Great },
new object[] { 5f, 64d, HitResult.Great },
new object[] { 5f, 65d, HitResult.Ok },
new object[] { 5f, 66d, HitResult.Ok },
new object[] { 5f, 118d, HitResult.Ok },
new object[] { 5f, 119d, HitResult.Ok },
new object[] { 5f, 120d, HitResult.Meh },
new object[] { 5f, 121d, HitResult.Meh },
new object[] { 5f, 173d, HitResult.Meh },
new object[] { 5f, 174d, HitResult.Meh },
new object[] { 5f, 175d, HitResult.Miss },
new object[] { 5f, 176d, HitResult.Miss },
};
private const double hit_circle_time = 100;
[TestCaseSource(nameof(no_mod_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 beatmap = createBeatmap(overallDifficulty);
var replay = new Replay
{
@@ -114,5 +158,91 @@ namespace osu.Game.Rulesets.Osu.Tests
RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(hard_rock_test_cases))]
public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
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,
Mods = [new OsuModHardRock()]
}
};
RunTest($@"HR single circle @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(easy_test_cases))]
public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
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,
Mods = [new OsuModEasy()]
}
};
RunTest($@"EZ single circle @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private static OsuBeatmap createBeatmap(float overallDifficulty)
{
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,
};
return beatmap;
}
}
}
@@ -7,6 +7,7 @@ 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.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Scoring;
@@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
protected override Ruleset CreateRuleset() => new TaikoRuleset();
private static readonly object[][] test_cases =
private static readonly object[][] no_mod_test_cases =
{
// With respect to notation,
// square brackets `[]` represent *closed* or *inclusive* bounds,
@@ -52,30 +53,58 @@ namespace osu.Game.Rulesets.Taiko.Tests
new object[] { 7.8f, -64d, HitResult.Miss },
};
[TestCaseSource(nameof(test_cases))]
private static readonly object[][] hard_rock_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 7.
// GREAT hit window is (-29ms, 29ms)
// OK hit window is (-68ms, 68ms)
new object[] { 5f, -27d, HitResult.Great },
new object[] { 5f, -28d, HitResult.Great },
new object[] { 5f, -29d, HitResult.Ok },
new object[] { 5f, -30d, HitResult.Ok },
new object[] { 5f, -66d, HitResult.Ok },
new object[] { 5f, -67d, HitResult.Ok },
new object[] { 5f, -68d, HitResult.Miss },
new object[] { 5f, -69d, HitResult.Miss },
// OD = 7.8 test cases.
// This would lead to "effective" OD of 10.92,
// but the effects are capped to OD 10.
// GREAT hit window is (-20ms, 20ms)
// OK hit window is (-50ms, 50ms)
new object[] { 7.8f, -18d, HitResult.Great },
new object[] { 7.8f, -19d, HitResult.Great },
new object[] { 7.8f, -20d, HitResult.Ok },
new object[] { 7.8f, -21d, HitResult.Ok },
new object[] { 7.8f, -48d, HitResult.Ok },
new object[] { 7.8f, -49d, HitResult.Ok },
new object[] { 7.8f, -50d, HitResult.Miss },
new object[] { 7.8f, -51d, HitResult.Miss },
};
private static readonly object[][] easy_test_cases =
{
// OD = 5 test cases.
// This leads to "effective" OD of 2.5.
// GREAT hit window is ( -42ms, 42ms)
// OK hit window is (-100ms, 100ms)
new object[] { 5f, -40d, HitResult.Great },
new object[] { 5f, -41d, HitResult.Great },
new object[] { 5f, -42d, HitResult.Ok },
new object[] { 5f, -43d, HitResult.Ok },
new object[] { 5f, -98d, HitResult.Ok },
new object[] { 5f, -99d, HitResult.Ok },
new object[] { 5f, -100d, HitResult.Miss },
new object[] { 5f, -101d, HitResult.Miss },
};
private const double hit_time = 100;
[TestCaseSource(nameof(no_mod_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 beatmap = createBeatmap(overallDifficulty);
var replay = new Replay
{
@@ -98,5 +127,85 @@ namespace osu.Game.Rulesets.Taiko.Tests
RunTest($@"single hit @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(hard_rock_test_cases))]
public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
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,
Mods = [new TaikoModHardRock()]
}
};
RunTest($@"HR single hit @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
[TestCaseSource(nameof(easy_test_cases))]
public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult)
{
var beatmap = createBeatmap(overallDifficulty);
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,
Mods = [new TaikoModHardRock()]
}
};
RunTest($@"EZ single hit @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]);
}
private static TaikoBeatmap createBeatmap(float overallDifficulty)
{
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,
};
return beatmap;
}
}
}
+130 -149
View File
@@ -2,14 +2,20 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using Moq;
using NUnit.Framework;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Localisation;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Taiko;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Utils;
@@ -182,98 +188,6 @@ namespace osu.Game.Tests.Mods
},
};
private static readonly object[] invalid_multiplayer_mod_test_scenarios =
{
// incompatible pair.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() },
new[] { typeof(OsuModHidden), typeof(OsuModApproachDifferent) }
},
// incompatible pair with derived class.
new object[]
{
new Mod[] { new OsuModDeflate(), new OsuModApproachDifferent() },
new[] { typeof(OsuModDeflate), typeof(OsuModApproachDifferent) }
},
// system mod.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModTouchDevice() },
new[] { typeof(OsuModTouchDevice) }
},
// multi mod.
new object[]
{
new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) },
new[] { typeof(MultiMod) }
},
// invalid multiplayer mod.
new object[]
{
new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() },
new[] { typeof(InvalidMultiplayerMod) }
},
// invalid free mod is valid for multiplayer global.
new object[]
{
new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() },
Array.Empty<Type>()
},
// valid pair.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModHardRock() },
Array.Empty<Type>()
},
};
private static readonly object[] invalid_free_mod_test_scenarios =
{
// system mod.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModTouchDevice() },
new[] { typeof(OsuModTouchDevice) }
},
// multi mod.
new object[]
{
new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) },
new[] { typeof(MultiMod) }
},
// invalid multiplayer mod.
new object[]
{
new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() },
new[] { typeof(InvalidMultiplayerMod) }
},
// invalid free mod.
new object[]
{
new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() },
new[] { typeof(InvalidMultiplayerFreeMod) }
},
// incompatible pair is valid for free mods.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() },
Array.Empty<Type>(),
},
// incompatible pair with derived class is valid for free mods.
new object[]
{
new Mod[] { new OsuModDeflate(), new OsuModSpinIn() },
Array.Empty<Type>(),
},
// valid pair.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModHardRock() },
Array.Empty<Type>()
},
};
[TestCaseSource(nameof(invalid_mod_test_scenarios))]
public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid)
{
@@ -287,32 +201,6 @@ namespace osu.Game.Tests.Mods
Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
}
[TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))]
public void TestInvalidMultiplayerModScenarios(Mod[] inputMods, Type[] expectedInvalid)
{
bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, out var invalid);
Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0));
if (isValid)
Assert.IsNull(invalid);
else
Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
}
[TestCaseSource(nameof(invalid_free_mod_test_scenarios))]
public void TestInvalidFreeModScenarios(Mod[] inputMods, Type[] expectedInvalid)
{
bool isValid = ModUtils.CheckValidFreeModsForMultiplayer(inputMods, out var invalid);
Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0));
if (isValid)
Assert.IsNull(invalid);
else
Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
}
[Test]
public void TestModBelongsToRuleset()
{
@@ -343,38 +231,127 @@ namespace osu.Game.Tests.Mods
Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x");
}
[Test]
public void TestRoomModValidity()
private static readonly object[] multiplayer_mod_test_scenarios =
{
Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.Playlists));
Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.Playlists));
Assert.IsTrue(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists));
Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.Playlists));
Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.Playlists));
// valid - as allowed mod.
new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []),
new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []),
// valid - as allowed mod (incompatible pair).
new MultiplayerTestScenario(false, false, [new OsuModHardRock(), new OsuModEasy()], []),
new MultiplayerTestScenario(false, true, [new OsuModHardRock(), new OsuModEasy()], []),
// valid - as allowed mod (incompatible pair with derived classes).
new MultiplayerTestScenario(false, false, [new OsuModDeflate(), new OsuModApproachDifferent()], []),
new MultiplayerTestScenario(false, true, [new OsuModDeflate(), new OsuModApproachDifferent()], []),
// valid - as allowed mod (not implemented in all rulesets).
new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []),
new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []),
// valid - as required mod.
new MultiplayerTestScenario(true, false, [new OsuModStrictTracking()], []),
// valid - as required mod when not freestyle.
new MultiplayerTestScenario(true, false, [new InvalidFreestyleRequiredMod()], []),
// valid - as required mod when freestyle (implemented in all rulesets).
new MultiplayerTestScenario(true, true, [new OsuModEasy()], []),
new MultiplayerTestScenario(true, true, [new OsuModNoFail()], []),
new MultiplayerTestScenario(true, true, [new OsuModHalfTime()], []),
new MultiplayerTestScenario(true, true, [new OsuModDaycore()], []),
new MultiplayerTestScenario(true, true, [new OsuModHardRock()], []),
new MultiplayerTestScenario(true, true, [new OsuModSuddenDeath()], []),
new MultiplayerTestScenario(true, true, [new OsuModPerfect()], []),
new MultiplayerTestScenario(true, true, [new OsuModDoubleTime()], []),
new MultiplayerTestScenario(true, true, [new OsuModNightcore()], []),
new MultiplayerTestScenario(true, true, [new OsuModHidden()], []),
new MultiplayerTestScenario(true, true, [new OsuModFlashlight()], []),
new MultiplayerTestScenario(true, true, [new OsuModAccuracyChallenge()], []),
new MultiplayerTestScenario(true, true, [new OsuModDifficultyAdjust()], []),
new MultiplayerTestScenario(true, true, [new ModWindUp()], []),
new MultiplayerTestScenario(true, true, [new ModWindDown()], []),
new MultiplayerTestScenario(true, true, [new OsuModMuted()], []),
Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.HeadToHead));
Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead));
// For now, adaptive speed isn't allowed in multiplayer because it's a per-user rate adjustment.
Assert.IsFalse(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead));
Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead));
Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead));
// invalid - always (system mod)
new MultiplayerTestScenario(false, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]),
new MultiplayerTestScenario(true, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]),
// invalid - always (multi mod).
new MultiplayerTestScenario(false, false, [new MultiMod()], [typeof(MultiMod)]),
new MultiplayerTestScenario(true, false, [new MultiMod()], [typeof(MultiMod)]),
// invalid - always (disallowed by mod)
new MultiplayerTestScenario(false, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]),
new MultiplayerTestScenario(true, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]),
new MultiplayerTestScenario(false, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]),
new MultiplayerTestScenario(true, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]),
// invalid - always (changes play length - for now not allowed in multiplayer).
new MultiplayerTestScenario(false, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]),
new MultiplayerTestScenario(true, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]),
// invalid - as allowed mod (disallowed by mod).
new MultiplayerTestScenario(false, false, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]),
new MultiplayerTestScenario(false, true, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]),
// invalid - as allowed mod (changes play length - for now not allowed in multiplayer).
new MultiplayerTestScenario(false, false, [new OsuModHalfTime()], [typeof(OsuModHalfTime)]),
new MultiplayerTestScenario(false, false, [new OsuModDaycore()], [typeof(OsuModDaycore)]),
new MultiplayerTestScenario(false, false, [new OsuModDoubleTime()], [typeof(OsuModDoubleTime)]),
new MultiplayerTestScenario(false, false, [new OsuModNightcore()], [typeof(OsuModNightcore)]),
// invalid - as required mod (incompatible pair)
new MultiplayerTestScenario(true, false, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]),
new MultiplayerTestScenario(true, true, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]),
new MultiplayerTestScenario(true, false, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]),
new MultiplayerTestScenario(true, true, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]),
// invalid - as required mod when freestyle (disallowed by mod).
new MultiplayerTestScenario(true, true, [new InvalidFreestyleRequiredMod()], [typeof(InvalidFreestyleRequiredMod)]),
// invalid - as required mod when freestyle (not implemented in all rulesets).
new MultiplayerTestScenario(true, true, [new OsuModStrictTracking()], [typeof(OsuModStrictTracking)]),
new MultiplayerTestScenario(true, true, [new OsuModBarrelRoll()], [typeof(OsuModBarrelRoll)]),
};
[TestCaseSource(nameof(multiplayer_mod_test_scenarios))]
public void TestMultiplayerModScenarios(MultiplayerTestScenario scenario)
{
List<Mod>? invalidMods;
bool isValid = scenario.IsRequired
? ModUtils.CheckValidRequiredModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods)
: ModUtils.CheckValidAllowedModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods);
Assert.That(isValid, Is.EqualTo(scenario.InvalidTypes.Length == 0));
if (isValid)
Assert.IsNull(invalidMods);
else
Assert.That(invalidMods?.Select(t => t.GetType()), Is.EquivalentTo(scenario.InvalidTypes));
}
[Test]
public void TestRoomFreeModValidity()
public void TestPlaylistsModScenarios()
{
Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.Playlists));
Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.Playlists));
Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists));
Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.Playlists));
Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.Playlists));
// The rest are tested by TestMultiplayerModScenarios.
Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), false, MatchType.Playlists, false));
Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), true, MatchType.Playlists, false));
Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), false, MatchType.Playlists, false));
Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), true, MatchType.Playlists, false));
Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), false, MatchType.Playlists, false));
Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), true, MatchType.Playlists, false));
}
Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.HeadToHead));
// For now, all rate adjustment mods aren't allowed as free mods in multiplayer.
Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead));
Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead));
Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead));
Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead));
[Test]
public void TestFreestyleRulesetCompatibility()
{
HashSet<string> commonAcronyms = new HashSet<string>();
commonAcronyms.UnionWith(new OsuRuleset().CreateAllMods().Select(m => m.Acronym));
commonAcronyms.IntersectWith(new TaikoRuleset().CreateAllMods().Select(m => m.Acronym));
commonAcronyms.IntersectWith(new CatchRuleset().CreateAllMods().Select(m => m.Acronym));
commonAcronyms.IntersectWith(new ManiaRuleset().CreateAllMods().Select(m => m.Acronym));
Assert.Multiple(() =>
{
foreach (var ruleset in new Ruleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() })
{
foreach (var mod in ruleset.CreateAllMods())
{
if (mod.ValidForFreestyleAsRequiredMod && mod.UserPlayable && !commonAcronyms.Contains(mod.Acronym))
Assert.Fail($"{mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but does not exist in all four basic rulesets!");
if (!mod.ValidForFreestyleAsRequiredMod && mod.UserPlayable && commonAcronyms.Contains(mod.Acronym))
Assert.Fail($"{mod.GetType().ReadableName()} does not declare {nameof(Mod.ValidForFreestyleAsRequiredMod)} but exists in all four basic rulesets!");
}
}
});
}
public abstract class CustomMod1 : Mod, IModCompatibilitySpecification
@@ -385,7 +362,7 @@ namespace osu.Game.Tests.Mods
{
}
public class InvalidMultiplayerMod : Mod
private class InvalidMultiplayerMod : Mod
{
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
@@ -406,18 +383,22 @@ namespace osu.Game.Tests.Mods
public override bool ValidForMultiplayerAsFreeMod => false;
}
public class EditableMod : Mod
public class InvalidFreestyleRequiredMod : Mod
{
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1;
public override string Acronym => string.Empty;
public override double ScoreMultiplier => Multiplier;
public double Multiplier = 1;
public override bool HasImplementation => true;
public override bool ValidForFreestyleAsRequiredMod => false;
}
public interface IModCompatibilitySpecification
public interface IModCompatibilitySpecification;
public readonly record struct MultiplayerTestScenario(bool IsRequired, bool IsFreestyle, Mod[] Mods, Type[] InvalidTypes)
{
public override string ToString()
=> $"{IsRequired}, {IsFreestyle}, [{string.Join(',', Mods.Select(m => m.GetType().ReadableName()))}], [{string.Join(',', InvalidTypes.Select(t => t.ReadableName()))}]";
}
}
}
@@ -1,10 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Overlays;
using osu.Game.Screens.Edit.Submission;
using osuTK;
@@ -16,9 +22,16 @@ namespace osu.Game.Tests.Visual.Editing
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Resolved]
private AudioManager audio { get; set; } = null!;
private Sample? completeSample;
[Test]
public void TestAppearance()
{
float incrementingProgress = 0;
SubmissionStageProgress progress = null!;
AddStep("create content", () => Child = new Container
@@ -36,12 +49,119 @@ namespace osu.Game.Tests.Visual.Editing
});
AddStep("not started", () => progress.SetNotStarted());
AddStep("indeterminate progress", () => progress.SetInProgress());
AddStep("30% progress", () => progress.SetInProgress(0.3f));
AddStep("70% progress", () => progress.SetInProgress(0.7f));
AddStep("increase progress to 100", () =>
{
incrementingProgress = 0;
ScheduledDelegate? task = null;
task = Scheduler.AddDelayed(() =>
{
if (incrementingProgress >= 1)
{
// ReSharper disable once AccessToModifiedClosure
task?.Cancel();
return;
}
if (RNG.NextDouble() < 0.01)
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f));
}, 0, true);
});
AddUntilStep("wait for completed", () => incrementingProgress >= 1);
AddStep("completed", () => progress.SetCompleted());
AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated"));
AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe"));
AddStep("canceled", () => progress.SetCanceled());
}
[Test]
public void TestAudioSequence()
{
SubmissionStageProgress[] stages = new SubmissionStageProgress[4];
Container? cardContainer = null;
AddStep("prepare", () =>
{
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Size = new Vector2(1),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 5),
Children = new Drawable[]
{
stages[0] = new SubmissionStageProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
StageDescription = "Export...",
StageIndex = 0
},
stages[1] = new SubmissionStageProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
StageDescription = "CreateSet...",
StageIndex = 1
},
stages[2] = new SubmissionStageProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
StageDescription = "Upload...",
StageIndex = 2
},
stages[3] = new SubmissionStageProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
StageDescription = "Update...",
StageIndex = 3
},
cardContainer = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}
}
}
};
completeSample = audio.Samples.Get(@"UI/bss-complete");
});
for (int i = 0; i < stages.Length; i++)
{
int step = i;
AddStep($"{step}: not started", () => stages[step].SetNotStarted());
AddStep($"{step}: indeterminate progress", () => stages[step].SetInProgress());
AddStep($"{step}: 25% progress", () => stages[step].SetInProgress(0.25f));
AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.7f));
AddStep($"{step}: completed", () => stages[step].SetCompleted());
}
AddWaitStep("pause for timing", 2);
AddStep("Sequence Complete", () =>
{
var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value);
beatmapSet.Beatmaps = Enumerable.Repeat(beatmapSet.Beatmaps.First(), 100).ToArray();
LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded =>
{
cardContainer?.Add(loaded);
completeSample?.Play();
});
});
}
}
}
@@ -0,0 +1,156 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Skinning;
using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Visual.Spectator;
namespace osu.Game.Tests.Visual.Gameplay
{
[Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")]
public partial class TestSceneHUDOverlayRulesetLayouts : OsuTestScene, IStorageResourceProvider
{
private readonly Dictionary<string, ISkin> skins = new Dictionary<string, ISkin>();
[Resolved]
private GameHost host { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
skins["argon"] = new ArgonSkin(this);
skins["triangles"] = new TrianglesSkin(this);
skins["legacy"] = new DefaultLegacySkin(this);
}
[Test]
public void TestLayout(
[Values("argon", "triangles", "legacy")]
string skinName,
[Values("osu", "taiko", "fruits", "mania")]
string rulesetName)
{
AddStep("create content", () =>
{
var rulesetInfo = rulesets.GetRuleset(rulesetName);
var ruleset = rulesetInfo!.CreateInstance();
var beatmap = ruleset.CreateBeatmapConverter(new Beatmap()).Convert();
var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap);
ISkin provider = ruleset.CreateSkinTransformer(skins[skinName], beatmap)!;
var gameplayState = TestGameplayState.Create(ruleset);
((Bindable<LocalUserPlayingState>)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing;
var spectatorClient = new TestSpectatorClient();
for (int i = 0; i < 15; ++i)
{
((ISpectatorClient)spectatorClient).UserStartedWatching([
new SpectatorUser
{
OnlineID = i,
Username = $"User {i}"
}
]);
}
GameplayClockContainer gameplayClock;
List<(Type, object)> dependencies =
[
(typeof(GameplayState), gameplayState),
(typeof(ScoreProcessor), gameplayState.ScoreProcessor),
(typeof(HealthProcessor), gameplayState.HealthProcessor),
(typeof(IGameplayClock), gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false)),
(typeof(SpectatorClient), spectatorClient),
(typeof(IGameplayLeaderboardProvider), new TestGameplayLeaderboardProvider()),
];
if (drawableRuleset is IDrawableScrollingRuleset scrolling)
dependencies.Add((typeof(IScrollingInfo), scrolling.ScrollingInfo));
Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = dependencies.ToArray(),
Children = new Drawable[]
{
spectatorClient,
new SkinProvidingContainer(provider)
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
drawableRuleset,
new HUDOverlay(drawableRuleset, [])
{
RelativeSizeAxes = Axes.Both,
}
}
}
}
};
gameplayClock.Start();
});
}
private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider
{
IBindableList<GameplayLeaderboardScore> IGameplayLeaderboardProvider.Scores => Scores;
public BindableList<GameplayLeaderboardScore> Scores { get; } = new BindableList<GameplayLeaderboardScore>();
public bool IsPartial { get; } = false;
public TestGameplayLeaderboardProvider()
{
for (int i = 0; i < 20; ++i)
{
Scores.Add(new GameplayLeaderboardScore(new ScoreInfo
{
User = new APIUser { Username = $"User {i}" },
TotalScore = (20 - i) * 50_000,
Accuracy = i * 0.05,
Combo = i * 50
}, i == 19));
}
}
}
#region IResourceStorageProvider
public IRenderer Renderer => host.Renderer;
public AudioManager AudioManager => Audio;
public IResourceStore<byte[]> Files => null!;
public new IResourceStore<byte[]> Resources => base.Resources;
public IResourceStore<TextureUpload> CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host.CreateTextureLoaderStore(underlyingStore);
RealmAccess IStorageResourceProvider.RealmAccess => null!;
#endregion
}
}
@@ -362,12 +362,14 @@ namespace osu.Game.Tests.Visual.Playlists
new PlaylistItem(importedSet.Beatmaps[0])
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
Freestyle = true
Freestyle = true,
AllowedMods = [new APIMod(new OsuModDoubleTime())]
},
new PlaylistItem(importedSet.Beatmaps[0])
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
Freestyle = true
Freestyle = true,
AllowedMods = [new APIMod(new OsuModDoubleTime())]
},
]
};
@@ -452,12 +454,14 @@ namespace osu.Game.Tests.Visual.Playlists
new PlaylistItem(importedSet.Beatmaps[0])
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
Freestyle = true
Freestyle = true,
AllowedMods = [new APIMod(new OsuModDoubleTime())]
},
new PlaylistItem(importedSet.Beatmaps[0])
{
RulesetID = new TaikoRuleset().RulesetInfo.OnlineID,
Freestyle = true
Freestyle = true,
AllowedMods = [new APIMod(new TaikoModDoubleTime())]
},
]
};
@@ -0,0 +1,185 @@
// 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.Testing;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.SongSelectV2
{
[TestFixture]
public partial class TestSceneBeatmapCarouselUpdateHandling : BeatmapCarouselTestScene
{
private BeatmapSetInfo baseTestBeatmap = null!;
[SetUpSteps]
public void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
AddBeatmaps(1, 3);
AddStep("generate and add test beatmap", () =>
{
baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(3);
var metadata = new BeatmapMetadata
{
Artist = "update test",
Title = "beatmap",
};
foreach (var b in baseTestBeatmap.Beatmaps)
b.Metadata = metadata;
BeatmapSets.Add(baseTestBeatmap);
});
WaitForSorting();
}
[Test]
public void TestBeatmapSetUpdatedNoop()
{
List<Panel> originalDrawables = new List<Panel>();
AddStep("store drawable references", () =>
{
originalDrawables.Clear();
originalDrawables.AddRange(Carousel.ChildrenOfType<Panel>().ToList());
});
AddStep("update beatmap with same reference", () => BeatmapSets.ReplaceRange(1, 1, [baseTestBeatmap]));
WaitForSorting();
AddAssert("drawables unchanged", () => Carousel.ChildrenOfType<Panel>(), () => Is.EqualTo(originalDrawables));
}
[Test]
public void TestBeatmapSetMetadataUpdated()
{
var metadata = new BeatmapMetadata
{
Artist = "updated test",
Title = "new beatmap title",
};
List<Panel> originalDrawables = new List<Panel>();
AddStep("store drawable references", () =>
{
originalDrawables.Clear();
originalDrawables.AddRange(Carousel.ChildrenOfType<Panel>().ToList());
});
updateBeatmap(b => b.Metadata = metadata);
WaitForSorting();
AddAssert("drawables changed", () => Carousel.ChildrenOfType<Panel>(), () => Is.Not.EqualTo(originalDrawables));
}
[Test]
public void TestSelectionHeld()
{
SelectPrevGroup();
WaitForSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
updateBeatmap();
WaitForSorting();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
[Test] // Checks that we keep selection based on online ID where possible.
public void TestSelectionHeldDifficultyNameChanged()
{
SelectPrevGroup();
WaitForSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
updateBeatmap(b => b.DifficultyName = "new name");
WaitForSorting();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
[Test] // Checks that we fallback to keeping selection based on difficulty name.
public void TestSelectionHeldDifficultyOnlineIDChanged()
{
SelectPrevGroup();
WaitForSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
updateBeatmap(b => b.OnlineID = b.OnlineID + 1);
WaitForSorting();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
private void updateBeatmap(Action<BeatmapInfo>? updateBeatmap = null, Action<BeatmapSetInfo>? updateSet = null)
{
AddStep("update beatmap with different reference", () =>
{
var updatedSet = new BeatmapSetInfo
{
ID = baseTestBeatmap.ID,
OnlineID = baseTestBeatmap.OnlineID,
DateAdded = baseTestBeatmap.DateAdded,
DateSubmitted = baseTestBeatmap.DateSubmitted,
DateRanked = baseTestBeatmap.DateRanked,
Status = baseTestBeatmap.Status,
StatusInt = baseTestBeatmap.StatusInt,
DeletePending = baseTestBeatmap.DeletePending,
Hash = baseTestBeatmap.Hash,
Protected = baseTestBeatmap.Protected,
};
updateSet?.Invoke(updatedSet);
var updatedBeatmaps = baseTestBeatmap.Beatmaps.Select(b =>
{
var updatedBeatmap = new BeatmapInfo
{
ID = b.ID,
Metadata = b.Metadata,
Ruleset = b.Ruleset,
DifficultyName = b.DifficultyName,
BeatmapSet = updatedSet,
Status = b.Status,
OnlineID = b.OnlineID,
Length = b.Length,
BPM = b.BPM,
Hash = b.Hash,
StarRating = b.StarRating,
MD5Hash = b.MD5Hash,
OnlineMD5Hash = b.OnlineMD5Hash,
};
updateBeatmap?.Invoke(updatedBeatmap);
return updatedBeatmap;
}).ToList();
updatedSet.Beatmaps.AddRange(updatedBeatmaps);
int originalIndex = BeatmapSets.IndexOf(baseTestBeatmap);
BeatmapSets.ReplaceRange(originalIndex, 1, [updatedSet]);
});
}
}
}
@@ -79,8 +79,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
selectBeatmap(null);
AddAssert("check default title", () => titleWedge.DisplayedTitle == Beatmap.Default.BeatmapInfo.Metadata.Title);
AddAssert("check default artist", () => titleWedge.DisplayedArtist == Beatmap.Default.BeatmapInfo.Metadata.Artist);
AddAssert("check empty version", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedVersion.ToString()));
AddAssert("check empty author", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedAuthor.ToString()));
AddAssert("check no statistics", () => difficultyDisplay.ChildrenOfType<BeatmapTitleWedge.DifficultyStatisticsDisplay>().All(d => !d.Statistics.Any()));
}
@@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AutoSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), new ImportScoreTest.TestArchiveReader(), "deadbeef")
Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), "deadbeef", new ImportScoreTest.TestArchiveReader())
};
}
}
@@ -28,7 +28,7 @@ namespace osu.Game.Database
[Resolved]
private RealmAccess realm { get; set; } = null!;
private readonly ArchiveReader scoreArchive;
private readonly ArchiveReader? scoreArchive;
private readonly APIBeatmapSet beatmapSetInfo;
private readonly string beatmapHash;
@@ -38,7 +38,13 @@ namespace osu.Game.Database
private IDisposable? realmSubscription;
public MissingBeatmapNotification(APIBeatmap beatmap, ArchiveReader scoreArchive, string beatmapHash)
/// <summary>
/// Creates a new notification about a missing beatmap that needs to be downloaded to proceed with an action.
/// </summary>
/// <param name="beatmap">The online-retrieved beatmap to download.</param>
/// <param name="beatmapHash">The hash of the beatmap that is required to proceed.</param>
/// <param name="scoreArchive">Optional archive with a score. If not <see langword="null"/>, a re-import of this archive will be attempted after the missing beatmap is downloaded.</param>
public MissingBeatmapNotification(APIBeatmap beatmap, string beatmapHash, ArchiveReader? scoreArchive)
{
beatmapSetInfo = beatmap.BeatmapSet!;
@@ -86,9 +92,13 @@ namespace osu.Game.Database
if (sender.Any(s => s.Beatmaps.Any(b => b.MD5Hash == beatmapHash)))
{
string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase));
var importTask = new ImportTask(scoreArchive.GetStream(name), name);
scoreManager.Import(new[] { importTask });
if (scoreArchive != null)
{
string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase));
var importTask = new ImportTask(scoreArchive.GetStream(name), name);
scoreManager.Import(new[] { importTask });
}
realmSubscription?.Dispose();
Close(false);
}
+14 -6
View File
@@ -166,6 +166,11 @@ namespace osu.Game.Graphics.Carousel
/// </summary>
protected virtual Task FilterAsync() => filterTask = performFilter();
/// <summary>
/// Check whether two models are the same for display purposes.
/// </summary>
protected virtual bool CheckModelEquality(object x, object y) => ReferenceEquals(x, y);
/// <summary>
/// Create a drawable for the given carousel item so it can be displayed.
/// </summary>
@@ -490,11 +495,11 @@ namespace osu.Game.Graphics.Carousel
updateItemYPosition(item, ref lastVisible, ref yPos);
if (ReferenceEquals(item.Model, currentKeyboardSelection.Model))
currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
if (CheckModelEquality(item.Model, currentKeyboardSelection.Model!))
currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, item.CarouselYPosition, i);
if (ReferenceEquals(item.Model, currentSelection.Model))
currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
if (CheckModelEquality(item.Model, currentSelection.Model!))
currentSelection = new Selection(currentSelection.Model, item, item.CarouselYPosition, i);
}
// If a keyboard selection is currently made, we want to keep the view stable around the selection.
@@ -578,7 +583,7 @@ namespace osu.Game.Graphics.Carousel
panel.X = GetPanelXOffset(panel);
c.Selected.Value = c.Item == currentSelection?.CarouselItem;
c.Selected.Value = currentSelection?.CarouselItem != null && CheckModelEquality(c.Item, currentSelection.CarouselItem);
c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem;
c.Expanded.Value = c.Item.IsExpanded;
}
@@ -644,7 +649,10 @@ namespace osu.Game.Graphics.Carousel
// The case where we're intending to display this panel, but it's already displayed.
// Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation.
var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model);
//
// Reference equality is used here instead of CheckModelEquality intentionally. In order to switch to `CheckModelEquality`,
// we need a way to signal to the drawable panels that there is an update.
var existing = toDisplay.FirstOrDefault(i => ReferenceEquals(i.Model, carouselPanel.Item!.Model));
if (existing != null)
{
@@ -14,6 +14,8 @@ namespace osu.Game.Graphics.Containers
/// </summary>
public partial class ExpandingContainer : Container, IExpandingContainer
{
public const double TRANSITION_DURATION = 500;
private readonly float contractedWidth;
private readonly float expandedWidth;
@@ -61,7 +63,7 @@ namespace osu.Game.Graphics.Containers
Expanded.BindValueChanged(v =>
{
this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, 500, Easing.OutQuint);
this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, TRANSITION_DURATION, Easing.OutQuint);
}, true);
}
@@ -6,6 +6,8 @@ using System.Collections.Generic;
using System.Linq;
using MessagePack;
using osu.Game.Online.API;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Online.Rooms
{
@@ -28,9 +30,20 @@ namespace osu.Game.Online.Rooms
[Key(4)]
public int RulesetID { get; set; }
/// <summary>
/// Mods that should be applied for every participant in the room.
/// </summary>
[Key(5)]
public IEnumerable<APIMod> RequiredMods { get; set; } = Enumerable.Empty<APIMod>();
/// <summary>
/// Mods that participants are allowed to apply at their own discretion.
/// </summary>
/// <remarks>
/// This will be empty when <see cref="Freestyle"/> is <c>true</c>, but participants may still select any mods from their choice of ruleset,
/// provided the mod <see cref="IMod.ValidForMultiplayerAsFreeMod">implementation</see> indicates free-mod validity
/// and is <see cref="ModUtils.CheckCompatibleSet(IEnumerable{Mod})">compatible</see> with the rest of the user's selection.
/// </remarks>
[Key(6)]
public IEnumerable<APIMod> AllowedMods { get; set; } = Enumerable.Empty<APIMod>();
@@ -57,7 +70,7 @@ namespace osu.Game.Online.Rooms
public double StarRating { get; set; }
/// <summary>
/// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset.
/// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty, ruleset, and mods.
/// </summary>
[Key(11)]
public bool Freestyle { get; set; }
+13 -1
View File
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Newtonsoft.Json;
@@ -9,6 +10,7 @@ using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Online.Rooms
@@ -37,9 +39,19 @@ namespace osu.Game.Online.Rooms
[JsonProperty("played_at")]
public DateTimeOffset? PlayedAt { get; set; }
/// <summary>
/// Mods that participants are allowed to apply at their own discretion.
/// </summary>
/// <remarks>
/// This will be empty when <see cref="Freestyle"/> is <c>true</c>, but participants may still select any mods from their choice of ruleset,
/// provided the mod is <see cref="ModUtils.CheckCompatibleSet(IEnumerable{Mod})">compatible</see> with the rest of the user's selection.
/// </remarks>
[JsonProperty("allowed_mods")]
public APIMod[] AllowedMods { get; set; } = Array.Empty<APIMod>();
/// <summary>
/// Mods that should be applied for every participant in the room.
/// </summary>
[JsonProperty("required_mods")]
public APIMod[] RequiredMods { get; set; } = Array.Empty<APIMod>();
@@ -68,7 +80,7 @@ namespace osu.Game.Online.Rooms
}
/// <summary>
/// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset.
/// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty, ruleset, and mods.
/// </summary>
[JsonProperty("freestyle")]
public bool Freestyle { get; set; }
+21 -9
View File
@@ -46,6 +46,7 @@ using osu.Game.Input.Bindings;
using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
using osu.Game.Online.Leaderboards;
using osu.Game.Online.Rooms;
@@ -59,6 +60,7 @@ using osu.Game.Overlays.SkinEditor;
using osu.Game.Overlays.Toolbar;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Footer;
@@ -742,23 +744,33 @@ namespace osu.Game
{
Logger.Log($"Beginning {nameof(PresentScore)} with score {score}");
var databasedScore = ScoreManager.GetScore(score);
Score databasedScore;
try
{
databasedScore = ScoreManager.GetScore(score);
}
catch (LegacyScoreDecoder.BeatmapNotFoundException notFound)
{
Logger.Log("The replay cannot be played because the beatmap is missing.", LoggingTarget.Information);
var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash });
req.Success += res => Notifications.Post(new MissingBeatmapNotification(res, notFound.Hash, null));
API.Queue(req);
return;
}
if (databasedScore == null) return;
if (databasedScore.Replay == null)
{
Logger.Log("The loaded score has no replay data.", LoggingTarget.Information);
Logger.Log("The loaded score has no replay data.", LoggingTarget.Information, LogLevel.Important);
return;
}
var databasedBeatmap = BeatmapManager.QueryBeatmap(b => b.ID == databasedScore.ScoreInfo.BeatmapInfo.ID);
if (databasedBeatmap == null)
{
Logger.Log("Tried to load a score for a beatmap we don't have!", LoggingTarget.Information);
return;
}
var databasedBeatmap = databasedScore.ScoreInfo.BeatmapInfo;
Debug.Assert(databasedBeatmap != null);
// This should be able to be performed from song select always, but that is disabled for now
// due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios).
+2 -2
View File
@@ -8,10 +8,10 @@ namespace osu.Game.Overlays.Rankings
{
public enum RankingsScope
{
[LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePerformance))]
[LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatPerformance))]
Performance,
[LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeScore))]
[LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatRankedScore))]
Score,
[LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))]
@@ -37,13 +37,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private readonly Bindable<Vector2> areaSize = new Bindable<Vector2>();
private readonly IBindable<TabletInfo> tablet = new Bindable<TabletInfo>();
private readonly BindableNumber<float> offsetX = new BindableNumber<float> { MinValue = 0 };
private readonly BindableNumber<float> offsetY = new BindableNumber<float> { MinValue = 0 };
private readonly BindableNumber<float> offsetX = new BindableNumber<float> { MinValue = 0, Precision = 1 };
private readonly BindableNumber<float> offsetY = new BindableNumber<float> { MinValue = 0, Precision = 1 };
private readonly BindableNumber<float> sizeX = new BindableNumber<float> { MinValue = 10 };
private readonly BindableNumber<float> sizeY = new BindableNumber<float> { MinValue = 10 };
private readonly BindableNumber<float> sizeX = new BindableNumber<float> { MinValue = 10, Precision = 1 };
private readonly BindableNumber<float> sizeY = new BindableNumber<float> { MinValue = 10, Precision = 1 };
private readonly BindableNumber<float> rotation = new BindableNumber<float> { MinValue = 0, MaxValue = 360 };
private readonly BindableNumber<float> rotation = new BindableNumber<float> { MinValue = 0, MaxValue = 360, Precision = 1 };
private readonly BindableNumber<float> pressureThreshold = new BindableNumber<float> { MinValue = 0.0f, MaxValue = 1.0f, Precision = 0.005f };
+7 -1
View File
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Mods
IconUsage? Icon { get; }
/// <summary>
/// Whether this mod is playable by an end user.
/// Whether this mod is playable by a real human user.
/// Should be <c>false</c> for cases where the user is not interacting with the game (so it can be excluded from multiplayer selection, for example).
/// </summary>
bool UserPlayable { get; }
@@ -53,6 +53,12 @@ namespace osu.Game.Rulesets.Mods
/// </summary>
bool ValidForMultiplayer { get; }
/// <summary>
/// Whether this mod is valid as a required mod when freestyle is enabled.
/// Should be <c>true</c> for mods that are guaranteed to be implemented across all rulesets.
/// </summary>
bool ValidForFreestyleAsRequiredMod { get; }
/// <summary>
/// Whether this mod is valid as a free mod in multiplayer matches.
/// Should be <c>false</c> for mods that affect the gameplay duration (e.g. <see cref="ModRateAdjust"/> and <see cref="ModTimeRamp"/>).
+2 -44
View File
@@ -87,56 +87,17 @@ namespace osu.Game.Rulesets.Mods
[JsonIgnore]
public virtual bool HasImplementation => this is IApplicableMod;
/// <summary>
/// Whether this mod can be played by a real human user.
/// Non-user-playable mods are not viable for single-player score submission.
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><see cref="ModDoubleTime"/> is user-playable.</item>
/// <item><see cref="ModAutoplay"/> is not user-playable.</item>
/// </list>
/// </example>
[JsonIgnore]
public virtual bool UserPlayable => true;
/// <summary>
/// Whether this mod can be specified as a "required" mod in a multiplayer context.
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><see cref="ModHardRock"/> is valid for multiplayer.</item>
/// <item>
/// <see cref="ModDoubleTime"/> is valid for multiplayer as long as it is a <b>required</b> mod,
/// as that ensures the same duration of gameplay for all users in the room.
/// </item>
/// <item>
/// <see cref="ModAdaptiveSpeed"/> is not valid for multiplayer, as it leads to varying
/// gameplay duration depending on how the users in the room play.
/// </item>
/// <item><see cref="ModAutoplay"/> is not valid for multiplayer.</item>
/// </list>
/// </example>
[JsonIgnore]
public virtual bool ValidForMultiplayer => true;
/// <summary>
/// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context.
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><see cref="ModHardRock"/> is valid for multiplayer as a free mod.</item>
/// <item>
/// <see cref="ModDoubleTime"/> is <b>not</b> valid for multiplayer as a free mod,
/// as it could to varying gameplay duration between users in the room depending on whether they picked it.
/// </item>
/// <item><see cref="ModAutoplay"/> is not valid for multiplayer as a free mod.</item>
/// </list>
/// </example>
public virtual bool ValidForFreestyleAsRequiredMod => false;
[JsonIgnore]
public virtual bool ValidForMultiplayerAsFreeMod => true;
/// <inheritdoc/>
[JsonIgnore]
public virtual bool AlwaysValidForSubmission => false;
@@ -146,9 +107,6 @@ namespace osu.Game.Rulesets.Mods
[JsonIgnore]
public virtual bool RequiresConfiguration => false;
/// <summary>
/// Whether scores with this mod active can give performance points.
/// </summary>
[JsonIgnore]
public virtual bool Ranked => false;
@@ -34,6 +34,8 @@ namespace osu.Game.Rulesets.Mods
public override bool Ranked => true;
public override bool ValidForFreestyleAsRequiredMod => true;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
+2
View File
@@ -30,5 +30,7 @@ namespace osu.Game.Rulesets.Mods
/// - Sliders always gives combo for slider end, even on miss (https://github.com/ppy/osu/issues/11769).
/// </summary>
public sealed override bool Ranked => false;
public sealed override bool ValidForFreestyleAsRequiredMod => true;
}
}
@@ -27,6 +27,8 @@ namespace osu.Game.Rulesets.Mods
public override bool RequiresConfiguration => true;
public override bool ValidForFreestyleAsRequiredMod => true;
public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) };
protected const int FIRST_SETTING_ORDER = 1;
+1
View File
@@ -17,6 +17,7 @@ namespace osu.Game.Rulesets.Mods
public override double ScoreMultiplier => 0.5;
public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) };
public override bool Ranked => UsesDefaultConfiguration;
public override bool ValidForFreestyleAsRequiredMod => true;
public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty)
{
+1
View File
@@ -37,6 +37,7 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyIncrease;
public override LocalisableString Description => "Restricted view area.";
public override bool Ranked => UsesDefaultConfiguration;
public override bool ValidForFreestyleAsRequiredMod => true;
[SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")]
public abstract BindableFloat SizeMultiplier { get; }
+1
View File
@@ -18,6 +18,7 @@ namespace osu.Game.Rulesets.Mods
public override LocalisableString Description => "Everything just got a bit harder...";
public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) };
public override bool Ranked => UsesDefaultConfiguration;
public override bool ValidForFreestyleAsRequiredMod => true;
protected const float ADJUST_RATIO = 1.4f;
+1
View File
@@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.ModHidden;
public override ModType Type => ModType.DifficultyIncrease;
public override bool Ranked => UsesDefaultConfiguration;
public override bool ValidForFreestyleAsRequiredMod => true;
public virtual void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
+1
View File
@@ -26,6 +26,7 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override bool Ranked => true;
public override bool ValidForFreestyleAsRequiredMod => true;
}
public abstract class ModMuted<TObject> : ModMuted, IApplicableToDrawableRuleset<TObject>, IApplicableToTrack, IApplicableToScoreProcessor
+1
View File
@@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Mods
public override double ScoreMultiplier => 0.5;
public override Type[] IncompatibleMods => new[] { typeof(ModFailCondition), typeof(ModCinema) };
public override bool Ranked => UsesDefaultConfiguration;
public override bool ValidForFreestyleAsRequiredMod => true;
private readonly Bindable<bool> showHealthBar = new Bindable<bool>();
+1
View File
@@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mods
public override double ScoreMultiplier => 1;
public override LocalisableString Description => "SS or quit.";
public override bool Ranked => true;
public override bool ValidForFreestyleAsRequiredMod => true;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModSuddenDeath), typeof(ModAccuracyChallenge) }).ToArray();
+1
View File
@@ -11,6 +11,7 @@ namespace osu.Game.Rulesets.Mods
{
public abstract class ModRateAdjust : Mod, IApplicableToRate
{
public sealed override bool ValidForFreestyleAsRequiredMod => true;
public sealed override bool ValidForMultiplayerAsFreeMod => false;
public abstract BindableNumber<double> SpeedChange { get; }
+1
View File
@@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mods
public override LocalisableString Description => "Miss and fail.";
public override double ScoreMultiplier => 1;
public override bool Ranked => true;
public override bool ValidForFreestyleAsRequiredMod => true;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray();
+1
View File
@@ -32,6 +32,7 @@ namespace osu.Game.Rulesets.Mods
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
public abstract BindableBool AdjustPitch { get; }
public sealed override bool ValidForFreestyleAsRequiredMod => true;
public sealed override bool ValidForMultiplayerAsFreeMod => false;
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) };
+1 -1
View File
@@ -59,7 +59,7 @@ namespace osu.Game.Scoring
{
// In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap.
var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash });
req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash));
req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, notFound.Hash, archive));
api.Queue(req);
}
@@ -47,7 +47,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
private Color4 selectedBackgroundColour;
private Color4 selectedIconColour;
protected Drawable Icon { get; private set; } = null!;
public Drawable Icon { get; private set; } = null!;
public DrawableTernaryButton()
{
@@ -40,11 +40,14 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
private readonly BindableList<Colour4> comboColours = new BindableList<Colour4>();
private readonly Bindable<bool> expanded = new Bindable<bool>(true);
private Container mainButtonContainer = null!;
private ColourPickerButton pickerButton = null!;
private DrawableTernaryButton mainButton = null!;
[BackgroundDependencyLoader]
private void load(EditorBeatmap editorBeatmap)
private void load(EditorBeatmap editorBeatmap, IExpandingContainer? expandableParent)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
@@ -54,7 +57,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Child = new DrawableTernaryButton
Child = mainButton = new DrawableTernaryButton
{
Current = Current,
Description = "New combo",
@@ -65,8 +68,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Alpha = 0,
Width = 25,
ComboColours = { BindTarget = comboColours }
}
};
@@ -74,6 +75,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects);
if (editorBeatmap.BeatmapSkin != null)
comboColours.BindTo(editorBeatmap.BeatmapSkin.ComboColours);
if (expandableParent != null)
expanded.BindTo(expandableParent.Expanded);
}
protected override void LoadComplete()
@@ -82,6 +86,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
selectedHitObjects.BindCollectionChanged((_, _) => updateState());
comboColours.BindCollectionChanged((_, _) => updateState());
expanded.BindValueChanged(_ => updateState());
Current.BindValueChanged(_ => updateState(), true);
}
@@ -89,14 +94,21 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
if (Current.Value == TernaryState.True && selectedHitObjects.Count == 1 && selectedHitObjects.Single() is IHasComboInformation hasCombo && comboColours.Count > 1)
{
mainButtonContainer.Padding = new MarginPadding { Right = 30 };
float targetPickerButtonWidth = expanded.Value ? 25 : 10;
pickerButton.ResizeWidthTo(targetPickerButtonWidth, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint);
pickerButton.SelectedHitObject.Value = hasCombo;
pickerButton.Alpha = 1;
pickerButton.Icon.Alpha = expanded.Value ? 1 : 0;
mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding { Right = targetPickerButtonWidth + 5 }, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint);
mainButton.Icon.MoveToX(expanded.Value ? 10 : 2.5f, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint);
}
else
{
mainButtonContainer.Padding = new MarginPadding();
pickerButton.Alpha = 0;
pickerButton.ResizeWidthTo(0, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint);
mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding(), ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint);
mainButton.Icon.MoveToX(10, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint);
}
}
@@ -111,12 +123,12 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
private SpriteIcon icon = null!;
public SpriteIcon Icon { get; private set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
Add(icon = new SpriteIcon
Add(Icon = new SpriteIcon
{
Icon = FontAwesome.Solid.Palette,
Size = new Vector2(16),
@@ -149,17 +161,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
{
Enabled.Value = SelectedHitObject.Value != null;
if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1)
if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1 || !SelectedHitObject.Value.NewCombo)
{
BackgroundColour = colourProvider.Background3;
icon.Colour = BackgroundColour.Darken(0.5f);
icon.Blending = BlendingParameters.Additive;
Icon.Colour = BackgroundColour.Darken(0.5f);
Icon.Blending = BlendingParameters.Additive;
}
else
{
BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)];
icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour);
icon.Blending = BlendingParameters.Inherit;
Icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour);
Icon.Blending = BlendingParameters.Inherit;
}
}
@@ -281,6 +281,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
SelectionAdditionBanksEnabled.Value = true;
SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True;
SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True;
foreach (var (_, sampleState) in SelectionSampleStates)
sampleState.Value = TernaryState.False;
}
/// <summary>
@@ -316,7 +318,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
// Reset the ternary states when the selection is cleared.
if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0)
if (SelectedItems.Count == 0)
Scheduler.AddOnce(resetTernaryStates);
else
Scheduler.AddOnce(UpdateTernaryStates);
@@ -44,7 +44,7 @@ namespace osu.Game.Screens.Edit.Setup
creatorTextBox = createTextBox<FormTextBox>(EditorSetupStrings.Creator),
difficultyTextBox = createTextBox<FormTextBox>(EditorSetupStrings.DifficultyName),
sourceTextBox = createTextBox<FormTextBox>(BeatmapsetsStrings.ShowInfoSource),
tagsTextBox = createTextBox<FormTextBox>(BeatmapsetsStrings.ShowInfoTags)
tagsTextBox = createTextBox<FormTextBox>(BeatmapsetsStrings.ShowInfoMapperTags)
};
if (setupScreen != null)
@@ -8,6 +8,8 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
@@ -81,8 +83,10 @@ namespace osu.Game.Screens.Edit.Submission
private Live<BeatmapSetInfo>? importedSet;
private Sample completedSample = null!;
[BackgroundDependencyLoader]
private void load()
private void load(AudioManager audio)
{
AddRangeInternal(new Drawable[]
{
@@ -118,24 +122,28 @@ namespace osu.Game.Screens.Edit.Submission
createSetStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Preparing,
StageIndex = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
exportStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Exporting,
StageIndex = 1,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
uploadStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Uploading,
StageIndex = 2,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
updateStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Finishing,
StageIndex = 3,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
@@ -181,6 +189,8 @@ namespace osu.Game.Screens.Edit.Submission
}
}
});
completedSample = audio.Samples.Get(@"UI/bss-complete");
}
private void createBeatmapSet()
@@ -382,6 +392,8 @@ namespace osu.Game.Screens.Edit.Submission
successContainer.Add(loaded);
flashLayer.FadeOutFromOne(2000, Easing.OutQuint);
});
completedSample.Play();
};
api.Queue(getBeatmapSetRequest);
@@ -1,13 +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 osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@@ -21,6 +26,8 @@ namespace osu.Game.Screens.Edit.Submission
{
public LocalisableString StageDescription { get; init; }
public int StageIndex { get; init; }
private Bindable<StageStatusType> status { get; } = new Bindable<StageStatusType>();
private Bindable<float?> progress { get; } = new Bindable<float?>();
@@ -33,8 +40,22 @@ namespace osu.Game.Screens.Edit.Submission
[Resolved]
private OsuColour colours { get; set; } = null!;
private Sample? progressSample;
private const int stage_done_sample_count = 4;
private Sample? stageDoneSample;
private Sample? errorSample;
private Sample? cancelSample;
private SampleChannel? progressSampleChannel;
private const int fadeout_duration = 100;
private ScheduledDelegate? progressSampleFadeDelegate;
private ScheduledDelegate? progressSampleStopDelegate;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
private void load(OverlayColourProvider colourProvider, AudioManager audio)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
@@ -111,6 +132,13 @@ namespace osu.Game.Screens.Edit.Submission
}
}
};
errorSample = audio.Samples.Get(@"UI/generic-error");
cancelSample = audio.Samples.Get(@"UI/notification-cancel");
progressSample = audio.Samples.Get(@"UI/bss-progress");
int stageSample = Math.Min(stage_done_sample_count - 1, StageIndex);
stageDoneSample = audio.Samples.Get(@$"UI/bss-stage-{stageSample}");
}
protected override void LoadComplete()
@@ -119,6 +147,8 @@ namespace osu.Game.Screens.Edit.Submission
status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true);
progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true);
progressSampleChannel = progressSample?.GetChannel();
}
public void SetNotStarted() => status.Value = StageStatusType.NotStarted;
@@ -127,6 +157,13 @@ namespace osu.Game.Screens.Edit.Submission
{
this.progress.Value = progress;
status.Value = StageStatusType.InProgress;
if (progressSampleChannel == null)
return;
progressSampleChannel.Frequency.Value = 0.5f;
progressSampleChannel.Volume.Value = 0.25f;
progressSampleChannel.Looping = true;
}
public void SetCompleted() => status.Value = StageStatusType.Completed;
@@ -139,14 +176,44 @@ namespace osu.Game.Screens.Edit.Submission
public void SetCanceled() => status.Value = StageStatusType.Canceled;
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
progressSampleChannel?.Stop();
}
private const float transition_duration = 200;
private const Easing transition_easing = Easing.OutQuint;
private void updateProgress()
{
if (progress.Value != null)
progressBar.ResizeWidthTo(progress.Value.Value, transition_duration, Easing.OutQuint);
progressSampleFadeDelegate?.Cancel();
progressSampleStopDelegate?.Cancel();
progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint);
progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, transition_easing);
if (progress.Value is float progressValue)
{
progressBar.ResizeWidthTo(progressValue, transition_duration, transition_easing);
if (progressSampleChannel == null || Precision.AlmostEquals(progressValue, 0f))
return;
// Don't restart the looping sample if already playing
if (!progressSampleChannel.Playing)
progressSampleChannel.Play();
this.TransformBindableTo(progressSampleChannel.Frequency, 0.5f + (progressValue * 1.5f), transition_duration, transition_easing);
this.TransformBindableTo(progressSampleChannel.Volume, 0.25f + (progressValue * .75f), transition_duration, transition_easing);
progressSampleFadeDelegate = Scheduler.AddDelayed(() =>
{
// Perform a fade-out before stopping the sample to prevent clicking.
this.TransformBindableTo(progressSampleChannel.Volume, 0, fadeout_duration);
progressSampleStopDelegate = Scheduler.AddDelayed(() => { progressSampleChannel.Stop(); }, fadeout_duration);
}, transition_duration - fadeout_duration);
}
}
private void updateStatus()
@@ -176,6 +243,12 @@ namespace osu.Game.Screens.Edit.Submission
};
iconContainer.Colour = colours.Green1;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
// manually set progress value, as to trigger sample playback for the final section
progress.Value = 1;
stageDoneSample?.Play();
break;
case StageStatusType.Failed:
@@ -186,6 +259,7 @@ namespace osu.Game.Screens.Edit.Submission
};
iconContainer.Colour = colours.Red1;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
errorSample?.Play();
break;
case StageStatusType.Canceled:
@@ -196,6 +270,7 @@ namespace osu.Game.Screens.Edit.Submission
};
iconContainer.Colour = colours.Gray8;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
cancelSample?.Play();
break;
}
}
@@ -81,14 +81,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem;
Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance();
Mod[] allowedMods = currentItem.Freestyle
? ruleset.AllMods.OfType<Mod>().Where(m => ModUtils.IsValidFreeModForMatchType(m, client.Room.Settings.MatchType)).ToArray()
: currentItem.AllowedMods.Select(m => m.ToMod(ruleset)).ToArray();
Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(client.Room.Settings.MatchType, currentItem.RequiredMods, currentItem.AllowedMods, currentItem.Freestyle, ruleset);
// Update the mod panels to reflect the ones which are valid for selection.
IsValidMod = allowedMods.Length > 0
? m => allowedMods.Any(a => a.GetType() == m.GetType())
: _ => false;
IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType());
// Remove any mods that are no longer allowed.
Mod[] newUserMods = SelectedMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray();
@@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay
freeModSelect = new FreeModSelectOverlay
{
SelectedMods = { BindTarget = FreeMods },
IsValidMod = isValidFreeMod,
IsValidMod = isValidAllowedMod,
};
}
@@ -115,45 +115,74 @@ namespace osu.Game.Screens.OnlinePlay
Freestyle.Value = initialItem.Freestyle;
}
Mods.BindValueChanged(onModsChanged);
Mods.BindValueChanged(onGlobalModsChanged);
Ruleset.BindValueChanged(onRulesetChanged);
Freestyle.BindValueChanged(onFreestyleChanged, true);
Freestyle.BindValueChanged(onFreestyleChanged);
freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect);
updateFooterButtons();
updateValidMods();
}
private void onFreestyleChanged(ValueChangedEvent<bool> enabled)
{
updateFooterButtons();
updateValidMods();
if (enabled.NewValue)
{
freeModsFooterButton.Enabled.Value = false;
freeModsFooterButton.Enabled.Value = false;
ModsFooterButton.Enabled.Value = false;
ModSelect.Hide();
freeModSelect.Hide();
Mods.Value = [];
// Freestyle allows all mods to be selected as freemods. This does not play nicely for some components:
// - We probably don't want to store a gigantic list of acronyms to the database.
// - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it.
// Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass.
FreeMods.Value = [];
}
else
{
freeModsFooterButton.Enabled.Value = true;
ModsFooterButton.Enabled.Value = true;
// When disabling freestyle, enable freemods by default.
FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray();
}
}
private void onModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods)
private void onGlobalModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods)
{
FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToList();
// Reset the validity delegate to update the overlay's display.
freeModSelect.IsValidMod = isValidFreeMod;
updateValidMods();
}
private void onRulesetChanged(ValueChangedEvent<RulesetInfo> ruleset)
{
FreeMods.Value = Array.Empty<Mod>();
// Todo: We can probably attempt to preserve across rulesets like the global mods do.
FreeMods.Value = [];
}
private void updateFooterButtons()
{
if (Freestyle.Value)
{
freeModsFooterButton.Enabled.Value = false;
freeModSelect.Hide();
}
else
freeModsFooterButton.Enabled.Value = true;
}
/// <summary>
/// Removes invalid mods from <see cref="OsuScreen.Mods"/> and <see cref="FreeMods"/>,
/// and updates mod selection overlays to display the new mods valid for selection.
/// </summary>
private void updateValidMods()
{
Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray();
if (!validMods.SequenceEqual(Mods.Value))
Mods.Value = validMods;
Mod[] validFreeMods = FreeMods.Value.Where(isValidAllowedMod).ToArray();
if (!validFreeMods.SequenceEqual(FreeMods.Value))
FreeMods.Value = validFreeMods;
ModSelect.IsValidMod = isValidRequiredMod;
freeModSelect.IsValidMod = isValidAllowedMod;
}
protected sealed override bool OnStart()
@@ -195,7 +224,7 @@ namespace osu.Game.Screens.OnlinePlay
protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum)
{
IsValidMod = isValidMod
IsValidMod = isValidRequiredMod
};
protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons()
@@ -221,22 +250,20 @@ namespace osu.Game.Screens.OnlinePlay
}
/// <summary>
/// Checks whether a given <see cref="Mod"/> is valid for global selection.
/// Checks whether a given <see cref="Mod"/> is valid to be selected as a required mod.
/// </summary>
/// <param name="mod">The <see cref="Mod"/> to check.</param>
/// <returns>Whether <paramref name="mod"/> is a valid mod for online play.</returns>
private bool isValidMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type);
private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value);
/// <summary>
/// Checks whether a given <see cref="Mod"/> is valid for per-player free-mod selection.
/// Checks whether a given <see cref="Mod"/> is valid to be selected as an allowed mod.
/// </summary>
/// <param name="mod">The <see cref="Mod"/> to check.</param>
/// <returns>Whether <paramref name="mod"/> is a selectable free-mod.</returns>
private bool isValidFreeMod(Mod mod) => ModUtils.IsValidFreeModForMatchType(mod, room.Type)
// Mod must not be contained in the required mods.
&& Mods.Value.All(m => m.Acronym != mod.Acronym)
// Mod must be compatible with all the required mods.
&& ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray());
private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value)
// Mod must not be contained in the required mods.
&& Mods.Value.All(m => m.Acronym != mod.Acronym)
// Mod must be compatible with all the required mods.
&& ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray());
protected override void Dispose(bool isDisposing)
{
@@ -19,6 +19,7 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.OnlinePlay.Playlists
@@ -34,6 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
private MultiplayerScores? higherScores;
private MultiplayerScores? lowerScores;
private WorkingBeatmap itemBeatmap = null!;
[Resolved]
protected IAPIProvider API { get; private set; } = null!;
@@ -60,6 +62,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
[BackgroundDependencyLoader]
private void load()
{
var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}",
PlaylistItem.Beatmap.OnlineID);
itemBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap);
AddInternal(new Container
{
RelativeSizeAxes = Axes.Both,
@@ -307,6 +313,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
}
}
protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(itemBeatmap);
private partial class PanelListLoadingSpinner : LoadingSpinner
{
private readonly ScorePanelList list;
@@ -572,31 +572,18 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
updateGameplayState();
}
/// <summary>
/// Lists the <see cref="Mod"/>s that are valid to be selected for the user mod style.
/// </summary>
private Mod[] listAllowedMods()
{
if (SelectedItem.Value == null)
return [];
PlaylistItem item = SelectedItem.Value;
RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!;
Ruleset rulesetInstance = gameplayRuleset.CreateInstance();
if (item.Freestyle)
return rulesetInstance.AllMods.OfType<Mod>().Where(m => ModUtils.IsValidFreeModForMatchType(m, room.Type)).ToArray();
return item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
}
/// <summary>
/// Validates the user mod style against the selected item and ruleset style.
/// </summary>
private void validateUserMods()
{
Mod[] allowedMods = listAllowedMods();
if (SelectedItem.Value == null)
return;
PlaylistItem item = SelectedItem.Value;
RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!;
Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance());
UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray();
}
@@ -613,7 +600,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap;
RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!;
Ruleset rulesetInstance = gameplayRuleset.CreateInstance();
Mod[] allowedMods = listAllowedMods();
Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance());
// Update global gameplay state to correspond to the new selection.
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
@@ -623,7 +610,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
Mods.Value = UserMods.Value.Concat(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToArray();
// Update UI elements to reflect the new selection.
bool freemods = allowedMods.Length > 0;
bool freemods = item.Freestyle || allowedMods.Length > 0;
bool freestyle = item.Freestyle;
if (freemods)
+68 -8
View File
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
@@ -74,18 +75,17 @@ namespace osu.Game.Screens.SelectV2
{
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
// right now we are managing this locally which is a bit of added overhead.
IEnumerable<BeatmapSetInfo>? newBeatmapSets = changed.NewItems?.Cast<BeatmapSetInfo>();
IEnumerable<BeatmapSetInfo>? beatmapSetInfos = changed.OldItems?.Cast<BeatmapSetInfo>();
IEnumerable<BeatmapSetInfo>? newItems = changed.NewItems?.Cast<BeatmapSetInfo>();
IEnumerable<BeatmapSetInfo>? oldItems = changed.OldItems?.Cast<BeatmapSetInfo>();
switch (changed.Action)
{
case NotifyCollectionChangedAction.Add:
Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps));
Items.AddRange(newItems!.SelectMany(s => s.Beatmaps));
break;
case NotifyCollectionChangedAction.Remove:
foreach (var set in beatmapSetInfos!)
foreach (var set in oldItems!)
{
foreach (var beatmap in set.Beatmaps)
Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
@@ -94,8 +94,50 @@ namespace osu.Game.Screens.SelectV2
break;
case NotifyCollectionChangedAction.Move:
// We can ignore move operations as we are applying our own sort in all cases.
break;
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException();
var oldSetBeatmaps = oldItems!.Single().Beatmaps;
var newSetBeatmaps = newItems!.Single().Beatmaps.ToList();
// Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set.
// Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered
// by users editing the beatmap or by difficulty/metadata recomputation).
//
// In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update.
// We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties
// have been processed) if it becomes an issue for animation or performance reasons.
foreach (var beatmap in oldSetBeatmaps)
{
int previousIndex = Items.IndexOf(beatmap);
Debug.Assert(previousIndex >= 0);
BeatmapInfo? matchingNewBeatmap =
newSetBeatmaps.SingleOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ??
newSetBeatmaps.SingleOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset));
if (matchingNewBeatmap != null)
{
// TODO: should this exist in song select instead of here?
// we need to ensure the global beatmap is also updated alongside changes.
if (CurrentSelection != null && CheckModelEquality(beatmap, CurrentSelection))
CurrentSelection = matchingNewBeatmap;
Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]);
newSetBeatmaps.Remove(matchingNewBeatmap);
}
else
{
Items.RemoveAt(previousIndex);
}
}
// Add any items which weren't found in the previous pass (difficulty names didn't match).
foreach (var beatmap in newSetBeatmaps)
Items.Add(beatmap);
break;
case NotifyCollectionChangedAction.Reset:
Items.Clear();
@@ -132,7 +174,7 @@ namespace osu.Game.Screens.SelectV2
return;
case BeatmapInfo beatmapInfo:
if (ReferenceEquals(CurrentSelection, beatmapInfo))
if (CurrentSelection != null && CheckModelEquality(CurrentSelection, beatmapInfo))
{
RequestPresentBeatmap?.Invoke(beatmapInfo);
return;
@@ -155,7 +197,7 @@ namespace osu.Game.Screens.SelectV2
case BeatmapInfo beatmapInfo:
// Find any containing group. There should never be too many groups so iterating is efficient enough.
GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key;
GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => CheckModelEquality(i.Model, beatmapInfo))).Key;
if (containingGroup != null)
setExpandedGroup(containingGroup);
@@ -311,6 +353,24 @@ namespace osu.Game.Screens.SelectV2
AddInternal(setPanelPool);
}
protected override bool CheckModelEquality(object x, object y)
{
// In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale
// BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs.
//
// If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring
// it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged
// before changing matching requirements here.
if (x is BeatmapSetInfo beatmapSetX && y is BeatmapSetInfo beatmapSetY)
return beatmapSetX.Equals(beatmapSetY);
if (x is BeatmapInfo beatmapX && y is BeatmapInfo beatmapY)
return beatmapX.Equals(beatmapY);
return base.CheckModelEquality(x, y);
}
protected override Drawable GetDrawableForDisplay(CarouselItem item)
{
switch (item.Model)
+5 -4
View File
@@ -68,10 +68,6 @@ namespace osu.Game.Skinning
switch (lookup)
{
case GlobalSkinnableContainerLookup containerLookup:
// Only handle global level defaults for now.
if (containerLookup.Ruleset != null)
return null;
switch (containerLookup.Lookup)
{
case GlobalSkinnableContainers.SongSelect:
@@ -83,6 +79,11 @@ namespace osu.Game.Skinning
return songSelectComponents;
case GlobalSkinnableContainers.MainHUDComponents:
if (containerLookup.Ruleset != null)
{
return new DefaultSkinComponentsContainer(_ => { });
}
var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container =>
{
var score = container.OfType<DefaultScoreCounter>().FirstOrDefault();
+41 -23
View File
@@ -128,12 +128,13 @@ namespace osu.Game.Utils
}
/// <summary>
/// Checks that all <see cref="Mod"/>s in a combination are valid as "required mods" in a multiplayer match session.
/// Checks whether the given combination of mods may be set as the <see cref="MultiplayerPlaylistItem.RequiredMods">required mods</see> of a multiplayer playlist item.
/// </summary>
/// <param name="mods">The mods to check.</param>
/// <param name="freestyle">Whether freestyle is enabled for the playlist item.</param>
/// <param name="invalidMods">Invalid mods, if any were found. Will be null if all mods were valid.</param>
/// <returns>Whether the input mods were all valid. If false, <paramref name="invalidMods"/> will contain all invalid entries.</returns>
public static bool CheckValidRequiredModsForMultiplayer(IEnumerable<Mod> mods, [NotNullWhen(false)] out List<Mod>? invalidMods)
public static bool CheckValidRequiredModsForMultiplayer(IEnumerable<Mod> mods, bool freestyle, [NotNullWhen(false)] out List<Mod>? invalidMods)
{
mods = mods.ToArray();
@@ -145,11 +146,11 @@ namespace osu.Game.Utils
if (!CheckCompatibleSet(mods, out invalidMods))
return false;
return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer, out invalidMods);
return checkValid(mods, m => IsValidModForMatch(m, true, MatchType.HeadToHead, freestyle), out invalidMods);
}
/// <summary>
/// Checks that all <see cref="Mod"/>s in a combination are valid as "free mods" in a multiplayer match session.
/// Checks whether the given mods are valid to appear as <see cref="MultiplayerPlaylistItem.AllowedMods">allowed mods</see> in a multiplayer playlist item.
/// </summary>
/// <remarks>
/// Note that this does not check compatibility between mods,
@@ -157,10 +158,11 @@ namespace osu.Game.Utils
/// not to be confused with the list of mods the user currently has selected for the multiplayer match.
/// </remarks>
/// <param name="mods">The mods to check.</param>
/// <param name="freestyle">Whether freestyle is enabled for the playlist item.</param>
/// <param name="invalidMods">Invalid mods, if any were found. Will be null if all mods were valid.</param>
/// <returns>Whether the input mods were all valid. If false, <paramref name="invalidMods"/> will contain all invalid entries.</returns>
public static bool CheckValidFreeModsForMultiplayer(IEnumerable<Mod> mods, [NotNullWhen(false)] out List<Mod>? invalidMods)
=> checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayerAsFreeMod && !(m is MultiMod), out invalidMods);
public static bool CheckValidAllowedModsForMultiplayer(IEnumerable<Mod> mods, bool freestyle, [NotNullWhen(false)] out List<Mod>? invalidMods)
=> checkValid(mods, m => IsValidModForMatch(m, false, MatchType.HeadToHead, freestyle), out invalidMods);
private static bool checkValid(IEnumerable<Mod> mods, Predicate<Mod> valid, [NotNullWhen(false)] out List<Mod>? invalidMods)
{
@@ -295,43 +297,59 @@ namespace osu.Game.Utils
}
/// <summary>
/// Determines whether a mod can be applied to playlist items in the given match type.
/// Determines whether a given mod is valid on a playlist item.
/// </summary>
/// <param name="mod">The mod to test.</param>
/// <param name="type">The match type.</param>
public static bool IsValidModForMatchType(Mod mod, MatchType type)
/// <param name="required">
/// <c>true</c> if the mod is intended as a <see cref="MultiplayerPlaylistItem.RequiredMods">required mod</see> on the target playlist item.
/// <c>false</c> if it is intended as an <see cref="MultiplayerPlaylistItem.AllowedMods">allowed mod</see>.
/// </param>
/// <param name="matchType">The type of match being played.</param>
/// <param name="freestyle">Whether the target playlist item enables <see cref="MultiplayerPlaylistItem.Freestyle">freestyle</see> mode.</param>
/// <seealso href="https://github.com/ppy/osu-web/blob/40936b514c6485b874f6c6496d55d9e8b1b88fd4/app/Singletons/Mods.php#L95-L113">Related osu!web function.</seealso>
public static bool IsValidModForMatch(Mod mod, bool required, MatchType matchType, bool freestyle)
{
if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation)
return false;
switch (type)
if (freestyle && required && !mod.ValidForFreestyleAsRequiredMod)
return false;
switch (matchType)
{
case MatchType.Playlists:
return true;
default:
return mod.ValidForMultiplayer;
return required ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod;
}
}
/// <summary>
/// Determines whether a mod can be applied as a free mod to playlist items in the given match type.
/// Given an online listing of mods and the user's preferred ruleset, gathers the mods which are selectable as free mods by the current user.
/// </summary>
/// <param name="mod">The mod to test.</param>
/// <param name="type">The match type.</param>
public static bool IsValidFreeModForMatchType(Mod mod, MatchType type)
/// <param name="matchType">The type of match being played.</param>
/// <param name="requiredMods">The required mods for the playlist item.</param>
/// <param name="allowedMods">The allowed mods for the playlist item.</param>
/// <param name="freestyle">Whether freestyle is enabled for the playlist item.</param>
/// <param name="userRuleset">The user's preferred ruleset, which may differ from the playlist item's selection on freestyle playlist items.</param>
public static Mod[] EnumerateUserSelectableFreeMods(MatchType matchType, IEnumerable<APIMod> requiredMods, IEnumerable<APIMod> allowedMods, bool freestyle, Ruleset userRuleset)
{
if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation)
return false;
switch (type)
if (freestyle)
{
case MatchType.Playlists:
return true;
Mod[] rulesetRequiredMods = requiredMods.Select(m => m.ToMod(userRuleset)).ToArray();
default:
return mod.ValidForMultiplayerAsFreeMod;
// In freestyle, the playlist item doesn't provide the allowed mods. Instead, all mods are unconditionally allowed by default.
return userRuleset.AllMods.OfType<Mod>()
// But the mods must still be compatible with the room...
.Where(m => IsValidModForMatch(m, false, matchType, true))
// ... And compatible with the required mods listing (this also handles de-duplication).
.Where(m => CheckCompatibleSet(rulesetRequiredMods.Append(m)))
.ToArray();
}
// Without freestyle, only the mods specified by the playlist item are valid.
return allowedMods.Select(m => m.ToMod(userRuleset)).ToArray();
}
}
}
+1 -1
View File
@@ -36,7 +36,7 @@
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.419.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2025.321.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2025.425.0" />
<PackageReference Include="Sentry" Version="5.1.1" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
<PackageReference Include="SharpCompress" Version="0.39.0" />