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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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"/>).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user