1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 17:27:24 +08:00

Merge branch 'master' into framework-IWindow-changes

This commit is contained in:
Dean Herbert 2023-07-07 20:57:48 +09:00
commit 1d9d90e6e3
125 changed files with 1732 additions and 442 deletions

View File

@ -187,7 +187,7 @@ namespace osu.Desktop
return edit.BeatmapInfo.ToString() ?? string.Empty;
case UserActivity.WatchingReplay watching:
return watching.BeatmapInfo.ToString();
return watching.BeatmapInfo?.ToString() ?? string.Empty;
case UserActivity.InLobby lobby:
return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value;

View File

@ -202,6 +202,8 @@ namespace osu.Game.Rulesets.Catch
public int LegacyID => 2;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new CatchLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame();
public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);

View File

@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// Todo: osu!catch should not output star rating in the 'aim' attribute.
yield return (ATTRIB_ID_AIM, StarRating);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@ -36,7 +35,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
StarRating = values[ATTRIB_ID_AIM];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
}
}
}

View File

@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@ -38,13 +41,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// this is the same as osu!, so there's potential to share the implementation... maybe
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
return new CatchDifficultyAttributes
CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
{
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor,
Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0,
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
};
if (ComputeLegacyScoringValues)
{
CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)

View File

@ -0,0 +1,142 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Catch.Difficulty
{
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int combo;
private double scoreMultiplier;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
int countSlider = 0;
int countSpinner = 0;
foreach (HitObject obj in baseBeatmap.HitObjects)
{
switch (obj)
{
case IHasPath:
countSlider++;
break;
case IHasDuration:
countSpinner++;
break;
default:
countNormal++;
break;
}
}
int objectCount = countNormal + countSlider + countSpinner;
int drainLength = 0;
if (baseBeatmap.HitObjects.Count > 0)
{
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
int difficultyPeppyStars = (int)Math.Round(
(baseBeatmap.Difficulty.DrainRate
+ baseBeatmap.Difficulty.OverallDifficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
}
private void simulateHit(HitObject hitObject)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
bool isBonus = false;
HitResult bonusResult = HitResult.None;
int scoreIncrease = 0;
switch (hitObject)
{
case TinyDroplet:
scoreIncrease = 10;
increaseCombo = false;
break;
case Droplet:
scoreIncrease = 100;
break;
case Fruit:
scoreIncrease = 300;
addScoreComboMultiplier = true;
increaseCombo = true;
break;
case Banana:
scoreIncrease = 1100;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.LargeBonus;
break;
case JuiceStream:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
return;
case BananaShower:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
return;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
}
}

View File

@ -24,7 +24,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
foreach (var v in base.ToDatabaseAttributes())
yield return v;
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
}
@ -33,7 +32,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
base.FromDatabaseAttributes(values, onlineInfo);
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
}

View File

@ -31,9 +31,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
}
@ -46,15 +50,26 @@ namespace osu.Game.Rulesets.Mania.Difficulty
HitWindows hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new ManiaDifficultyAttributes
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
StarRating = skills[0].DifficultyValue() * star_scaling_factor,
Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window.
// This is done the way it is to introduce fractional differences in order to match osu-stable for the time being.
GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate),
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject)
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
if (ComputeLegacyScoringValues)
{
ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
private static int maxComboForObject(HitObject hitObject)

View File

@ -0,0 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Difficulty
{
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore => 0;
public int ComboScore { get; private set; }
public double BonusScoreRatio => 0;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn))
.Select(m => m.ScoreMultiplier)
.Aggregate(1.0, (c, n) => c * n);
ComboScore = (int)(1000000 * multiplier);
}
}
}

View File

@ -302,6 +302,8 @@ namespace osu.Game.Rulesets.Mania
public int LegacyID => 3;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new ManiaLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo);

View File

@ -93,7 +93,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightRating())
@ -111,7 +110,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficulty = values[ATTRIB_ID_SPEED];
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];

View File

@ -26,9 +26,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@ -71,7 +74,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
);
double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0;
double starRating = basePerformance > 0.00001
? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4)
: 0;
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate;
@ -86,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return new OsuDifficultyAttributes
OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{
StarRating = starRating,
Mods = mods,
@ -103,6 +108,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SliderCount = sliderCount,
SpinnerCount = spinnerCount,
};
if (ComputeLegacyScoringValues)
{
OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)

View File

@ -0,0 +1,177 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty
{
internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int combo;
private double scoreMultiplier;
private IBeatmap playableBeatmap = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
this.playableBeatmap = playableBeatmap;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
int countSlider = 0;
int countSpinner = 0;
foreach (HitObject obj in workingBeatmap.Beatmap.HitObjects)
{
switch (obj)
{
case IHasPath:
countSlider++;
break;
case IHasDuration:
countSpinner++;
break;
default:
countNormal++;
break;
}
}
int objectCount = countNormal + countSlider + countSpinner;
int drainLength = 0;
if (baseBeatmap.HitObjects.Count > 0)
{
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
int difficultyPeppyStars = (int)Math.Round(
(baseBeatmap.Difficulty.DrainRate
+ baseBeatmap.Difficulty.OverallDifficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
}
private void simulateHit(HitObject hitObject)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
bool isBonus = false;
HitResult bonusResult = HitResult.None;
int scoreIncrease = 0;
switch (hitObject)
{
case SliderHeadCircle:
case SliderTailCircle:
case SliderRepeat:
scoreIncrease = 30;
break;
case SliderTick:
scoreIncrease = 10;
break;
case SpinnerBonusTick:
scoreIncrease = 1100;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.LargeBonus;
break;
case SpinnerTick:
scoreIncrease = 100;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.SmallBonus;
break;
case HitCircle:
scoreIncrease = 300;
addScoreComboMultiplier = true;
break;
case Slider:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
scoreIncrease = 300;
increaseCombo = false;
addScoreComboMultiplier = true;
break;
case Spinner spinner:
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
// We'll redo the calculations to match osu-stable here...
const double maximum_rotations_per_second = 477.0 / 60;
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
double secondsDuration = spinner.Duration / 1000;
// The total amount of half spins possible for the entire spinner.
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
for (int i = 0; i <= totalHalfSpinsPossible; i++)
{
if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0)
simulateHit(new SpinnerBonusTick());
else if (i > 1 && i % 2 == 0)
simulateHit(new SpinnerTick());
}
scoreIncrease = 300;
addScoreComboMultiplier = true;
break;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
}
}

View File

@ -253,6 +253,8 @@ namespace osu.Game.Rulesets.Osu
public int LegacyID => 0;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new OsuLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new OsuRulesetConfigManager(settings, RulesetInfo);

View File

@ -72,13 +72,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
}
[Test]
@ -100,13 +100,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test]
@ -145,23 +145,23 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(120);
AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL);
seekTo(480);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(700);
AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second));
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test]
@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
StartTime = 100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong
}
};
hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -184,13 +184,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit.StrongNestedHit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(200);
AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Hit>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
}
[Test]
@ -213,18 +213,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
}
[Test]
@ -247,18 +247,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT);
}
[Test]
@ -272,8 +272,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
EndTime = 1100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM),
new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong
}
};
drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -282,18 +282,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
});
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(600);
AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRollTick>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
seekTo(1200);
AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf<DrumRoll>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM);
}
[Test]
@ -319,18 +319,18 @@ namespace osu.Game.Rulesets.Taiko.Tests
// This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits.
// But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(600);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK);
}
[Test]
@ -344,7 +344,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
EndTime = 1100,
Samples = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum")
new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM)
}
};
swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -356,25 +356,26 @@ namespace osu.Game.Rulesets.Taiko.Tests
// This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits.
// But for sample playback purposes they can be ignored as noise.
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
seekTo(600);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
seekTo(1200);
AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf<Swell>);
checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum");
checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum");
checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM);
checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM);
}
private void checkSound(HitType hitType, string expectedName, string expectedBank)
private void checkSamples(HitType hitType, string expectedSamplesCsv, string expectedBank)
{
AddStep($"hit {hitType}", () => triggerSource.Play(hitType));
AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Name, () => Is.EqualTo(expectedName));
AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Single().Bank, () => Is.EqualTo(expectedBank));
AddAssert($"last played sample is {expectedSamplesCsv}", () => string.Join(',', triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().Select(s => s.Name)),
() => Is.EqualTo(expectedSamplesCsv));
AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType<HitSampleInfo>().First().Bank, () => Is.EqualTo(expectedBank));
}
private void seekTo(double time) => AddStep($"seek to {time}", () => gameplayClock.Seek(time));

View File

@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
private void addFlyingHit(HitType hitType)
{
var tick = new DrumRollTick { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current };
var tick = new DrumRollTick(new DrumRoll()) { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current };
DrawableDrumRollTick h;
DrawableRuleset.Playfield.Add(h = new DrawableDrumRollTick(tick) { JudgementType = hitType });

View File

@ -3,15 +3,16 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.UI;
namespace osu.Game.Rulesets.Taiko.Tests
{
/// <summary>
/// Taiko has some interesting rules for legacy mappings.
/// Taiko doesn't output any samples. They are all handled externally by <see cref="DrumSamplePlayer"/>.
/// </summary>
[HeadlessTest]
public partial class TestSceneSampleOutput : TestSceneTaikoPlayer
@ -26,10 +27,10 @@ namespace osu.Game.Rulesets.Taiko.Tests
string.Empty,
string.Empty,
string.Empty,
HitSampleInfo.HIT_FINISH,
HitSampleInfo.HIT_WHISTLE,
HitSampleInfo.HIT_WHISTLE,
HitSampleInfo.HIT_WHISTLE,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
};
var actualSampleNames = new List<string>();
@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
AddUntilStep("all samples collected", () => actualSampleNames.Count == expectedSampleNames.Length);
AddAssert("samples are correct", () => actualSampleNames.SequenceEqual(expectedSampleNames));
AddAssert("samples are correct", () => actualSampleNames, () => Is.EqualTo(expectedSampleNames));
}
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions");

View File

@ -48,7 +48,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
foreach (var v in base.ToDatabaseAttributes())
yield return v;
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
}
@ -57,7 +56,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
base.FromDatabaseAttributes(values, onlineInfo);
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
}

View File

@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
@ -84,7 +87,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
HitWindows hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
return new TaikoDifficultyAttributes
TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes
{
StarRating = starRating,
Mods = mods,
@ -95,6 +98,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
};
if (ComputeLegacyScoringValues)
{
TaikoLegacyScoreSimulator sv1Simulator = new TaikoLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
/// <summary>

View File

@ -0,0 +1,202 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
internal class TaikoLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int combo;
private double modMultiplier;
private int difficultyPeppyStars;
private IBeatmap playableBeatmap = null!;
private IReadOnlyList<Mod> mods = null!;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{
this.playableBeatmap = playableBeatmap;
this.mods = mods;
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
int countNormal = 0;
int countSlider = 0;
int countSpinner = 0;
foreach (HitObject obj in baseBeatmap.HitObjects)
{
switch (obj)
{
case IHasPath:
countSlider++;
break;
case IHasDuration:
countSpinner++;
break;
default:
countNormal++;
break;
}
}
int objectCount = countNormal + countSlider + countSpinner;
int drainLength = 0;
if (baseBeatmap.HitObjects.Count > 0)
{
int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
difficultyPeppyStars = (int)Math.Round(
(baseBeatmap.Difficulty.DrainRate
+ baseBeatmap.Difficulty.OverallDifficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
modMultiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
}
private void simulateHit(HitObject hitObject)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
bool isBonus = false;
HitResult bonusResult = HitResult.None;
int scoreIncrease = 0;
switch (hitObject)
{
case SwellTick:
scoreIncrease = 300;
increaseCombo = false;
break;
case DrumRollTick:
scoreIncrease = 300;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.SmallBonus;
break;
case Swell swell:
// The taiko swell generally does not match the osu-stable implementation in any way.
// We'll redo the calculations to match osu-stable here...
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
double secondsDuration = swell.Duration / 1000;
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
halfSpinsRequiredForCompletion = (int)Math.Max(1, halfSpinsRequiredForCompletion * 1.65f);
if (mods.Any(m => m is ModDoubleTime))
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 0.75f));
if (mods.Any(m => m is ModHalfTime))
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f));
for (int i = 0; i <= halfSpinsRequiredForCompletion; i++)
simulateHit(new SwellTick());
scoreIncrease = 300;
addScoreComboMultiplier = true;
increaseCombo = false;
isBonus = true;
bonusResult = HitResult.LargeBonus;
break;
case Hit:
scoreIncrease = 300;
addScoreComboMultiplier = true;
break;
case DrumRoll:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
return;
}
if (hitObject is DrumRollTick tick)
{
if (playableBeatmap.ControlPointInfo.EffectPointAt(tick.Parent.StartTime).KiaiMode)
scoreIncrease = (int)(scoreIncrease * 1.2f);
if (tick.IsStrong)
scoreIncrease += scoreIncrease / 5;
}
// The score increase directly contributed to by the combo-multiplied portion.
int comboScoreIncrease = 0;
if (addScoreComboMultiplier)
{
int oldScoreIncrease = scoreIncrease;
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
scoreIncrease += (int)(scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * modMultiplier) * (Math.Min(100, combo) / 10);
if (hitObject is Swell)
{
if (playableBeatmap.ControlPointInfo.EffectPointAt(hitObject.GetEndTime()).KiaiMode)
scoreIncrease = (int)(scoreIncrease * 1.2f);
}
else
{
if (playableBeatmap.ControlPointInfo.EffectPointAt(hitObject.StartTime).KiaiMode)
scoreIncrease = (int)(scoreIncrease * 1.2f);
}
comboScoreIncrease = scoreIncrease - oldScoreIncrease;
}
if (hitObject is Swell || (hitObject is TaikoStrongableHitObject strongable && strongable.IsStrong))
{
scoreIncrease *= 2;
comboScoreIncrease *= 2;
}
scoreIncrease -= comboScoreIncrease;
if (addScoreComboMultiplier)
ComboScore += comboScoreIncrease;
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
}
}

View File

@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
// TODO: The detection of rulesets is temporary until the leftover old skills have been reworked.
bool isConvert = score.BeatmapInfo.Ruleset.OnlineID != 1;
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
double multiplier = 1.13;

View File

@ -4,14 +4,12 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning.Default;
@ -93,40 +91,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
? new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit)
: new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit);
public override IEnumerable<HitSampleInfo> GetSamples()
{
// normal and claps are always handled by the drum (see DrumSampleMapping).
// in addition, whistles are excluded as they are an alternative rim marker.
var samples = HitObject.Samples.Where(s =>
s.Name != HitSampleInfo.HIT_NORMAL
&& s.Name != HitSampleInfo.HIT_CLAP
&& s.Name != HitSampleInfo.HIT_WHISTLE);
if (HitObject.Type == HitType.Rim && HitObject.IsStrong)
{
// strong + rim always maps to whistle.
// TODO: this should really be in the legacy decoder, but can't be because legacy encoding parity would be broken.
// when we add a taiko editor, this is probably not going to play nice.
var corrected = samples.ToList();
for (int i = 0; i < corrected.Count; i++)
{
var s = corrected[i];
if (s.Name != HitSampleInfo.HIT_FINISH)
continue;
corrected[i] = s.With(HitSampleInfo.HIT_WHISTLE);
}
return corrected;
}
return samples;
}
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
Debug.Assert(HitObject.HitWindows != null);

View File

@ -119,8 +119,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override bool RemoveWhenNotAlive => false;
}
// Most osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource).
public override IEnumerable<HitSampleInfo> GetSamples() => Enumerable.Empty<HitSampleInfo>();
// osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource).
public sealed override IEnumerable<HitSampleInfo> GetSamples() => Enumerable.Empty<HitSampleInfo>();
}
public abstract partial class DrawableTaikoHitObject<TObject> : DrawableTaikoHitObject

View File

@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Taiko.Objects
{
cancellationToken.ThrowIfCancellationRequested();
AddNested(new DrumRollTick
AddNested(new DrumRollTick(this)
{
FirstTick = first,
TickSpacing = tickSpacing,

View File

@ -9,6 +9,8 @@ namespace osu.Game.Rulesets.Taiko.Objects
{
public class DrumRollTick : TaikoStrongableHitObject
{
public readonly DrumRoll Parent;
/// <summary>
/// Whether this is the first (initial) tick of the slider.
/// </summary>
@ -25,6 +27,11 @@ namespace osu.Game.Rulesets.Taiko.Objects
/// </summary>
public double HitWindow => TickSpacing / 2;
public DrumRollTick(DrumRoll parent)
{
Parent = parent;
}
public override Judgement CreateJudgement() => new TaikoDrumRollTickJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -8,6 +9,7 @@ using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Objects;
@ -26,11 +28,12 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
private Bindable<int> currentCombo { get; } = new BindableInt();
private int animationFrame;
private double beatLength;
// required for editor blueprints (not sure why these circle pieces are zero size).
public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad;
private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT;
public LegacyCirclePiece()
{
RelativeSizeAxes = Axes.Both;
@ -39,11 +42,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
[Resolved(canBeNull: true)]
private GameplayState? gameplayState { get; set; }
[Resolved(canBeNull: true)]
private IBeatSyncProvider? beatSyncProvider { get; set; }
[BackgroundDependencyLoader]
private void load(ISkinSource skin, DrawableHitObject drawableHitObject)
private void load(ISkinSource skin, DrawableHitObject drawableHitObject, IBeatSyncProvider? beatSyncProvider)
{
Drawable? getDrawableFor(string lookup)
{
@ -64,6 +64,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
if (foregroundLayer != null)
AddInternal(foregroundLayer);
drawableHitObject.StartTimeBindable.BindValueChanged(startTime =>
{
timingPoint = beatSyncProvider?.ControlPoints?.TimingPointAt(startTime.NewValue) ?? TimingControlPoint.DEFAULT;
}, true);
// Animations in taiko skins are used in a custom way (>150 combo and animating in time with beat).
// For now just stop at first frame for sanity.
foreach (var c in InternalChildren)
@ -115,15 +120,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
return;
}
if (beatSyncProvider?.ControlPoints != null)
{
beatLength = beatSyncProvider.ControlPoints.TimingPointAt(Time.Current).BeatLength;
animationFrame = Time.Current % ((beatLength * 2) / multiplier) >= beatLength / multiplier ? 0 : 1;
animationFrame = Math.Abs(Time.Current - timingPoint.Time) % ((timingPoint.BeatLength * 2) / multiplier) >= timingPoint.BeatLength / multiplier ? 0 : 1;
animatableForegroundLayer.GotoFrame(animationFrame);
}
}
private Color4 accentColour;

View File

@ -197,6 +197,8 @@ namespace osu.Game.Rulesets.Taiko
public int LegacyID => 1;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new TaikoLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new TaikoRulesetConfigManager(settings, RulesetInfo);

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.UI;
@ -18,12 +17,25 @@ namespace osu.Game.Rulesets.Taiko.UI
public void Play(HitType hitType)
{
var hitSample = GetMostValidObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL);
TaikoHitObject? hitObject = GetMostValidObject() as TaikoHitObject;
if (hitSample == null)
if (hitObject == null)
return;
PlaySamples(new ISampleInfo[] { new HitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL, hitSample.Bank, volume: hitSample.Volume) });
var baseSample = hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL);
if ((hitObject as TaikoStrongableHitObject)?.IsStrong == true || hitObject is StrongNestedHitObject)
{
PlaySamples(new ISampleInfo[]
{
baseSample,
hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_WHISTLE : HitSampleInfo.HIT_FINISH)
});
}
else
{
PlaySamples(new ISampleInfo[] { baseSample });
}
}
public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead");

View File

@ -64,7 +64,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestCachedRetrievalWithFiles() => AddStep("run test", () =>
{
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID).Detach());
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID)!.Detach());
Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0));
@ -90,7 +90,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestForcedRefetchRetrievalWithFiles() => AddStep("run test", () =>
{
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID).Detach());
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID)!.Detach());
Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0));
@ -102,7 +102,7 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestSavePreservesCollections() => AddStep("run test", () =>
{
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID).Detach());
var beatmap = Realm.Run(r => r.Find<BeatmapInfo>(importedSet.Beatmaps.First().ID)!.Detach());
var working = beatmaps.GetWorkingBeatmap(beatmap);

View File

@ -42,7 +42,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});
@ -51,7 +51,7 @@ namespace osu.Game.Tests.Database
{
Realm.Write(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
foreach (var b in beatmapSetInfo.Beatmaps)
b.StarRating = -1;
});
@ -66,7 +66,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});
@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});
@ -90,7 +90,7 @@ namespace osu.Game.Tests.Database
{
Realm.Write(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
foreach (var b in beatmapSetInfo.Beatmaps)
b.StarRating = -1;
});
@ -107,7 +107,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating == -1);
});
});
@ -118,7 +118,7 @@ namespace osu.Game.Tests.Database
{
return Realm.Run(r =>
{
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID);
var beatmapSetInfo = r.Find<BeatmapSetInfo>(importedSet.ID)!;
return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0);
});
});

View File

@ -18,6 +18,7 @@ using osu.Game.Extensions;
using osu.Game.Models;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Tests.Resources;
using Realms;
using SharpCompress.Archives;
@ -416,6 +417,108 @@ namespace osu.Game.Tests.Database
});
}
[Test]
public void TestImport_Modify_Revert()
{
RunTestWithRealmAsync(async (realm, storage) =>
{
var importer = new BeatmapImporter(storage, realm);
using var store = new RealmRulesetStore(realm, storage);
var imported = await LoadOszIntoStore(importer, realm.Realm);
await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First());
var score = realm.Run(r => r.All<ScoreInfo>().Single());
string originalHash = imported.Beatmaps.First().Hash;
const string modified_hash = "new_hash";
Assert.That(imported.Beatmaps.First().Scores.Single(), Is.EqualTo(score));
Assert.That(score.BeatmapHash, Is.EqualTo(originalHash));
Assert.That(score.BeatmapInfo, Is.EqualTo(imported.Beatmaps.First()));
// imitate making local changes via editor
// ReSharper disable once MethodHasAsyncOverload
realm.Write(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = modified_hash;
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
Assert.That(!imported.Beatmaps.First().Scores.Any());
Assert.That(score.BeatmapInfo, Is.Null);
Assert.That(score.BeatmapHash, Is.EqualTo(originalHash));
// imitate reverting the local changes made above
// ReSharper disable once MethodHasAsyncOverload
realm.Write(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = originalHash;
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
Assert.That(imported.Beatmaps.First().Scores.Single(), Is.EqualTo(score));
Assert.That(score.BeatmapHash, Is.EqualTo(originalHash));
Assert.That(score.BeatmapInfo, Is.EqualTo(imported.Beatmaps.First()));
});
}
[Test]
public void TestImport_ThenModifyMapWithScore_ThenImport()
{
RunTestWithRealmAsync(async (realm, storage) =>
{
var importer = new BeatmapImporter(storage, realm);
using var store = new RealmRulesetStore(realm, storage);
string? temp = TestResources.GetTestBeatmapForImport();
var imported = await LoadOszIntoStore(importer, realm.Realm);
await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First());
Assert.That(imported.Beatmaps.First().Scores.Any());
// imitate making local changes via editor
// ReSharper disable once MethodHasAsyncOverload
realm.Write(r =>
{
BeatmapInfo beatmap = imported.Beatmaps.First();
beatmap.Hash = "new_hash";
beatmap.ResetOnlineInfo();
beatmap.UpdateLocalScores(r);
});
Assert.That(!imported.Beatmaps.First().Scores.Any());
var importedSecondTime = await importer.Import(new ImportTask(temp));
EnsureLoaded(realm.Realm);
// check the newly "imported" beatmap is not the original.
Assert.NotNull(importedSecondTime);
Debug.Assert(importedSecondTime != null);
Assert.That(imported.ID != importedSecondTime.ID);
var importedFirstTimeBeatmap = imported.Beatmaps.First();
var importedSecondTimeBeatmap = importedSecondTime.PerformRead(s => s.Beatmaps.First());
Assert.That(importedFirstTimeBeatmap.ID != importedSecondTimeBeatmap.ID);
Assert.That(importedFirstTimeBeatmap.Hash != importedSecondTimeBeatmap.Hash);
Assert.That(!importedFirstTimeBeatmap.Scores.Any());
Assert.That(importedSecondTimeBeatmap.Scores.Count() == 1);
Assert.That(importedSecondTimeBeatmap.Scores.Single().BeatmapInfo, Is.EqualTo(importedSecondTimeBeatmap));
});
}
[Test]
public void TestImportThenImportWithChangedFile()
{
@ -1074,18 +1177,16 @@ namespace osu.Game.Tests.Database
Assert.IsTrue(realm.All<BeatmapSetInfo>().First(_ => true).DeletePending);
}
private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap)
private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) =>
realm.WriteAsync(() =>
{
// TODO: reimplement when we have score support in realm.
// return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo
// {
// OnlineID = 2,
// Beatmap = beatmap,
// BeatmapInfoID = beatmap.ID
// }, new ImportScoreTest.TestArchiveReader());
return Task.CompletedTask;
}
realm.Add(new ScoreInfo
{
OnlineID = 2,
BeatmapInfo = beatmap,
BeatmapHash = beatmap.Hash
});
});
private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false)
{

View File

@ -323,7 +323,7 @@ namespace osu.Game.Tests.Database
var beatmapInfo = s.Beatmaps.First(b => b.File?.Filename != removedFilename);
scoreTargetBeatmapHash = beatmapInfo.Hash;
s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
});
realm.Run(r => r.Refresh());
@ -347,6 +347,73 @@ namespace osu.Game.Tests.Database
});
}
[Test]
public void TestDanglingScoreTransferred()
{
RunTestWithRealmAsync(async (realm, storage) =>
{
var importer = new BeatmapImporter(storage, realm);
using var rulesets = new RealmRulesetStore(realm, storage);
using var __ = getBeatmapArchive(out string pathOriginal);
using var _ = getBeatmapArchive(out string pathOnlineCopy);
var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal));
Assert.That(importBeforeUpdate, Is.Not.Null);
Debug.Assert(importBeforeUpdate != null);
string scoreTargetBeatmapHash = string.Empty;
// set a score on the beatmap
importBeforeUpdate.PerformWrite(s =>
{
var beatmapInfo = s.Beatmaps.First();
scoreTargetBeatmapHash = beatmapInfo.Hash;
s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
});
// locally modify beatmap
const string new_beatmap_hash = "new_hash";
importBeforeUpdate.PerformWrite(s =>
{
var beatmapInfo = s.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash);
beatmapInfo.Hash = new_beatmap_hash;
beatmapInfo.ResetOnlineInfo();
beatmapInfo.UpdateLocalScores(s.Realm!);
});
realm.Run(r => r.Refresh());
// making changes to a beatmap doesn't remove the score from realm, but should disassociate the beatmap.
checkCount<ScoreInfo>(realm, 1);
Assert.That(realm.Run(r => r.All<ScoreInfo>().First().BeatmapInfo), Is.Null);
// reimport the original beatmap before local modifications
var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOnlineCopy), importBeforeUpdate.Value);
Assert.That(importAfterUpdate, Is.Not.Null);
Debug.Assert(importAfterUpdate != null);
realm.Run(r => r.Refresh());
// both original and locally modified versions present
checkCount<BeatmapInfo>(realm, count_beatmaps + 1);
checkCount<BeatmapMetadata>(realm, count_beatmaps + 1);
checkCount<BeatmapSetInfo>(realm, 2);
// score is preserved
checkCount<ScoreInfo>(realm, 1);
// score is transferred to new beatmap
Assert.That(importBeforeUpdate.Value.Beatmaps.First(b => b.Hash == new_beatmap_hash).Scores, Has.Count.EqualTo(0));
Assert.That(importAfterUpdate.Value.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash).Scores, Has.Count.EqualTo(1));
});
}
[Test]
public void TestScoreLostOnModification()
{
@ -368,7 +435,7 @@ namespace osu.Game.Tests.Database
{
var beatmapInfo = s.Beatmaps.Last();
scoreTargetFilename = beatmapInfo.File?.Filename;
s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All<RulesetInfo>().First(), new RealmUser()));
});
realm.Run(r => r.Refresh());
@ -461,7 +528,7 @@ namespace osu.Game.Tests.Database
importBeforeUpdate.PerformWrite(s =>
{
var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection"));
var beatmapCollection = s.Realm!.Add(new BeatmapCollection("test collection"));
beatmapsToAddToCollection = s.Beatmaps.Count - (allOriginalBeatmapsInCollection ? 0 : 1);
for (int i = 0; i < beatmapsToAddToCollection; i++)
@ -476,7 +543,7 @@ namespace osu.Game.Tests.Database
importAfterUpdate.PerformRead(updated =>
{
updated.Realm.Refresh();
updated.Realm!.Refresh();
string[] hashes = updated.Realm.All<BeatmapCollection>().Single().BeatmapMD5Hashes.ToArray();
@ -526,7 +593,7 @@ namespace osu.Game.Tests.Database
importBeforeUpdate.PerformWrite(s =>
{
var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection"));
var beatmapCollection = s.Realm!.Add(new BeatmapCollection("test collection"));
originalHash = s.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash;
beatmapCollection.BeatmapMD5Hashes.Add(originalHash);
@ -540,7 +607,7 @@ namespace osu.Game.Tests.Database
importAfterUpdate.PerformRead(updated =>
{
updated.Realm.Refresh();
updated.Realm!.Refresh();
string[] hashes = updated.Realm.All<BeatmapCollection>().Single().BeatmapMD5Hashes.ToArray();
string updatedHash = updated.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash;

View File

@ -128,7 +128,7 @@ namespace osu.Game.Tests.Database
realm.RegisterCustomSubscription(r =>
{
var subscription = r.All<BeatmapInfo>().QueryAsyncWithNotifications((_, _, _) =>
var subscription = r.All<BeatmapInfo>().QueryAsyncWithNotifications((_, _) =>
{
realm.Run(_ =>
{

View File

@ -355,7 +355,7 @@ namespace osu.Game.Tests.Database
return null;
});
void gotChange(IRealmCollection<BeatmapInfo> sender, ChangeSet changes, Exception error)
void gotChange(IRealmCollection<BeatmapInfo> sender, ChangeSet? changes)
{
changesTriggered++;
}

View File

@ -1,7 +1,6 @@
// 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 System.Threading;
@ -54,7 +53,7 @@ namespace osu.Game.Tests.Database
registration.Dispose();
});
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error)
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes)
{
lastChanges = changes;
@ -92,7 +91,7 @@ namespace osu.Game.Tests.Database
registration.Dispose();
});
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error) => lastChanges = changes;
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes) => lastChanges = changes;
}
[Test]
@ -185,7 +184,7 @@ namespace osu.Game.Tests.Database
}
});
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error)
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes)
{
if (changes == null)
resolvedItems = sender;

View File

@ -76,12 +76,12 @@ namespace osu.Game.Tests.Database
Available = true,
}));
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.True);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore
var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.False);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
});
}
@ -101,18 +101,18 @@ namespace osu.Game.Tests.Database
Available = true,
}));
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.True);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore
var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.False);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
// Simulate the ruleset getting updated
LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION;
var __ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName).Available), Is.True);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
});
}

View File

@ -104,7 +104,7 @@ namespace osu.Game.Tests.Database
realm.Run(innerRealm =>
{
var binding = innerRealm.ResolveReference(tsr);
var binding = innerRealm.ResolveReference(tsr)!;
innerRealm.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace));
});

View File

@ -3,6 +3,7 @@
#nullable disable
using System;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
@ -80,7 +81,9 @@ namespace osu.Game.Tests.Gameplay
{
TestLifetimeEntry entry = null;
AddStep("Create entry", () => entry = new TestLifetimeEntry(new HitObject()) { LifetimeStart = 1 });
assertJudged(() => entry, false);
AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()));
assertJudged(() => entry, false);
AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET);
TestDrawableHitObject dho = null;
@ -91,6 +94,7 @@ namespace osu.Game.Tests.Gameplay
});
AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()));
AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY);
assertJudged(() => entry, false);
}
[Test]
@ -138,6 +142,29 @@ namespace osu.Game.Tests.Gameplay
AddAssert("DHO state is correct", () => dho.State.Value == ArmedState.Miss);
}
[Test]
public void TestJudgedStateThroughLifetime()
{
TestDrawableHitObject dho = null;
HitObjectLifetimeEntry lifetimeEntry = null;
AddStep("Create lifetime entry", () => lifetimeEntry = new HitObjectLifetimeEntry(new HitObject { StartTime = Time.Current }));
assertJudged(() => lifetimeEntry, false);
AddStep("Create DHO and apply entry", () =>
{
Child = dho = new TestDrawableHitObject();
dho.Apply(lifetimeEntry);
});
assertJudged(() => lifetimeEntry, false);
AddStep("Apply result", () => dho.MissForcefully());
assertJudged(() => lifetimeEntry, true);
}
[Test]
public void TestResultSetBeforeLoadComplete()
{
@ -154,15 +181,20 @@ namespace osu.Game.Tests.Gameplay
}
};
});
assertJudged(() => lifetimeEntry, true);
AddStep("Create DHO and apply entry", () =>
{
dho = new TestDrawableHitObject();
dho.Apply(lifetimeEntry);
Child = dho;
});
assertJudged(() => lifetimeEntry, true);
AddAssert("DHO state is correct", () => dho.State.Value, () => Is.EqualTo(ArmedState.Hit));
}
private void assertJudged(Func<HitObjectLifetimeEntry> entry, bool val) =>
AddAssert(val ? "Is judged" : "Not judged", () => entry().Judged, () => Is.EqualTo(val));
private partial class TestDrawableHitObject : DrawableHitObject
{
public const double INITIAL_LIFETIME_OFFSET = 100;

View File

@ -87,10 +87,10 @@ namespace osu.Game.Tests.Models
var mock = new Mock<IScoreInfo>();
mock.Setup(m => m.User).Returns(new APIUser { Username = "user" }); // TODO: temporary.
mock.Setup(m => m.Beatmap.Metadata.Artist).Returns("artist");
mock.Setup(m => m.Beatmap.Metadata.Title).Returns("title");
mock.Setup(m => m.Beatmap.Metadata.Author.Username).Returns("author");
mock.Setup(m => m.Beatmap.DifficultyName).Returns("difficulty");
mock.Setup(m => m.Beatmap!.Metadata.Artist).Returns("artist");
mock.Setup(m => m.Beatmap!.Metadata.Title).Returns("title");
mock.Setup(m => m.Beatmap!.Metadata.Author.Username).Returns("author");
mock.Setup(m => m.Beatmap!.DifficultyName).Returns("difficulty");
Assert.That(mock.Object.GetDisplayString(), Is.EqualTo("user playing artist - title (author) [difficulty]"));
}

View File

@ -131,7 +131,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First()));
AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null));
AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID)).Mods.First(), () => Is.EqualTo(playerMods.First()));
AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID))!.Mods.First(), () => Is.EqualTo(playerMods.First()));
}
[Test]

View File

@ -170,7 +170,16 @@ namespace osu.Game.Tests.Visual.Gameplay
ManualClock clock = null;
var beatmap = new Beatmap();
beatmap.HitObjects.Add(new TestHitObjectWithNested { Duration = 40 });
beatmap.HitObjects.Add(new TestHitObjectWithNested
{
Duration = 40,
NestedObjects = new HitObject[]
{
new PooledNestedHitObject { StartTime = 10 },
new PooledNestedHitObject { StartTime = 20 },
new PooledNestedHitObject { StartTime = 30 }
}
});
createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock()));
@ -209,6 +218,49 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("object judged", () => playfield.JudgedObjects.Count == 1);
}
[Test]
public void TestPooledObjectWithNonPooledNesteds()
{
ManualClock clock = null;
TestHitObjectWithNested hitObjectWithNested;
var beatmap = new Beatmap();
beatmap.HitObjects.Add(hitObjectWithNested = new TestHitObjectWithNested
{
Duration = 40,
NestedObjects = new HitObject[]
{
new PooledNestedHitObject { StartTime = 10 },
new NonPooledNestedHitObject { StartTime = 20 },
new NonPooledNestedHitObject { StartTime = 30 }
}
});
createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock()));
AddAssert("hitobject entry has all nesteds", () => playfield.HitObjectContainer.Entries.Single().NestedEntries, () => Has.Count.EqualTo(3));
AddStep("skip to middle of object", () => clock.CurrentTime = (hitObjectWithNested.StartTime + hitObjectWithNested.GetEndTime()) / 2);
AddAssert("2 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(2));
AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False);
AddStep("skip to before end of object", () => clock.CurrentTime = hitObjectWithNested.GetEndTime() - 1);
AddAssert("3 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(3));
AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False);
AddStep("removing object doesn't crash", () => playfield.Remove(hitObjectWithNested));
AddStep("clear judged", () => playfield.JudgedObjects.Clear());
AddStep("add object back", () => playfield.Add(hitObjectWithNested));
AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False);
AddStep("skip to long past object", () => clock.CurrentTime = 100_000);
// the parent entry should still be linked to nested entries of pooled objects that are managed externally
// but not contain synthetic entries that were created for the non-pooled objects.
AddAssert("entry still has non-synthetic nested entries", () => playfield.HitObjectContainer.Entries.Single().NestedEntries, () => Has.Count.EqualTo(1));
AddAssert("entry all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.True);
}
private void createTest(IBeatmap beatmap, int poolSize, Func<IFrameBasedClock> createClock = null)
{
AddStep("create test", () =>
@ -289,7 +341,7 @@ namespace osu.Game.Tests.Visual.Gameplay
RegisterPool<TestHitObject, DrawableTestHitObject>(poolSize);
RegisterPool<TestKilledHitObject, DrawableTestKilledHitObject>(poolSize);
RegisterPool<TestHitObjectWithNested, DrawableTestHitObjectWithNested>(poolSize);
RegisterPool<NestedHitObject, DrawableNestedHitObject>(poolSize);
RegisterPool<PooledNestedHitObject, DrawableNestedHitObject>(poolSize);
}
protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new TestHitObjectLifetimeEntry(hitObject);
@ -422,16 +474,22 @@ namespace osu.Game.Tests.Visual.Gameplay
private class TestHitObjectWithNested : TestHitObject
{
public IEnumerable<HitObject> NestedObjects { get; init; } = Array.Empty<HitObject>();
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
base.CreateNestedHitObjects(cancellationToken);
for (int i = 0; i < 3; ++i)
AddNested(new NestedHitObject { StartTime = (float)Duration * (i + 1) / 4 });
foreach (var ho in NestedObjects)
AddNested(ho);
}
}
private class NestedHitObject : ConvertHitObject
private class PooledNestedHitObject : ConvertHitObject
{
}
private class NonPooledNestedHitObject : ConvertHitObject
{
}
@ -482,6 +540,9 @@ namespace osu.Game.Tests.Visual.Gameplay
nestedContainer.Clear(false);
}
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
=> hitObject is NonPooledNestedHitObject nonPooled ? new DrawableNestedHitObject(nonPooled) : null;
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
base.CheckForResult(userTriggered, timeOffset);
@ -490,25 +551,30 @@ namespace osu.Game.Tests.Visual.Gameplay
}
}
private partial class DrawableNestedHitObject : DrawableHitObject<NestedHitObject>
private partial class DrawableNestedHitObject : DrawableHitObject
{
public DrawableNestedHitObject()
: this(null)
{
}
public DrawableNestedHitObject(NestedHitObject hitObject)
public DrawableNestedHitObject(PooledNestedHitObject hitObject)
: base(hitObject)
{
}
public DrawableNestedHitObject(NonPooledNestedHitObject hitObject)
: base(hitObject)
{
Size = new Vector2(15);
Colour = Colour4.White;
RelativePositionAxes = Axes.Both;
Origin = Anchor.Centre;
}
[BackgroundDependencyLoader]
private void load()
{
Size = new Vector2(15);
Colour = Colour4.White;
RelativePositionAxes = Axes.Both;
Origin = Anchor.Centre;
AddInternal(new Circle
{
RelativeSizeAxes = Axes.Both,

View File

@ -107,6 +107,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestBeatmapDownloadingStates()
{
AddStep("set to unknown", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Unknown()));
AddStep("set to no map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded()));
AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0)));
@ -382,6 +383,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
AddUntilStep("wait for list to load", () => participantsList?.IsLoaded == true);
AddStep("set beatmap available", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.LocallyAvailable()));
}
private void checkProgressBarVisibility(bool visible) =>

View File

@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.Ranking
AddStep("show excess mods score", () =>
{
var score = TestResources.CreateTestScoreInfo();
score.Mods = score.BeatmapInfo.Ruleset.CreateInstance().CreateAllMods().ToArray();
score.Mods = score.BeatmapInfo!.Ruleset.CreateInstance().CreateAllMods().ToArray();
showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), score);
});
}

View File

@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Ranking
var author = new RealmUser { Username = "mapper_name" };
var score = TestResources.CreateTestScoreInfo(createTestBeatmap(author));
score.Mods = score.BeatmapInfo.Ruleset.CreateInstance().CreateAllMods().ToArray();
score.Mods = score.BeatmapInfo!.Ruleset.CreateInstance().CreateAllMods().ToArray();
showPanel(score);
});

View File

@ -405,7 +405,7 @@ namespace osu.Game.Tests.Visual.Ranking
public UnrankedSoloResultsScreen(ScoreInfo score)
: base(score, true)
{
Score.BeatmapInfo.OnlineID = 0;
Score.BeatmapInfo!.OnlineID = 0;
Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending;
}

View File

@ -163,7 +163,7 @@ namespace osu.Game.Tests.Visual.SongSelect
InputManager.Key(Key.Enter);
});
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddAssert("ensure selection changed", () => selected != Beatmap.Value);
}
@ -186,7 +186,7 @@ namespace osu.Game.Tests.Visual.SongSelect
InputManager.Key(Key.Down);
});
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddAssert("ensure selection didn't change", () => selected == Beatmap.Value);
}
@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.SongSelect
InputManager.Key(Key.Enter);
});
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddAssert("ensure selection changed", () => selected != Beatmap.Value);
}
@ -244,7 +244,7 @@ namespace osu.Game.Tests.Visual.SongSelect
InputManager.ReleaseButton(MouseButton.Left);
});
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddAssert("ensure selection didn't change", () => selected == Beatmap.Value);
}
@ -257,7 +257,7 @@ namespace osu.Game.Tests.Visual.SongSelect
createSongSelect();
AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child")));
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddStep("return", () => songSelect!.MakeCurrent());
AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen());
@ -275,7 +275,7 @@ namespace osu.Game.Tests.Visual.SongSelect
createSongSelect();
AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child")));
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true));
@ -292,7 +292,7 @@ namespace osu.Game.Tests.Visual.SongSelect
createSongSelect();
AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child")));
AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddStep("update beatmap", () =>
{
@ -1011,7 +1011,7 @@ namespace osu.Game.Tests.Visual.SongSelect
});
});
AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap()));
AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0);
@ -1040,7 +1040,7 @@ namespace osu.Game.Tests.Visual.SongSelect
songSelect!.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap()));
});
AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen());
waitForDismissed();
AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap()));
AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0);
@ -1161,6 +1161,8 @@ namespace osu.Game.Tests.Visual.SongSelect
rulesets.Dispose();
}
private void waitForDismissed() => AddUntilStep("wait for not current", () => !songSelect.AsNonNull().IsCurrentScreen());
private partial class TestSongSelect : PlaySongSelect
{
public Action? StartRequested;

View File

@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.UserInterface
var testPresets = createTestPresets();
foreach (var preset in testPresets)
preset.Ruleset = realm.Find<RulesetInfo>(preset.Ruleset.ShortName);
preset.Ruleset = realm.Find<RulesetInfo>(preset.Ruleset.ShortName)!;
realm.Add(testPresets);
});
@ -103,7 +103,7 @@ namespace osu.Game.Tests.Visual.UserInterface
new ManiaModNightcore(),
new ManiaModHardRock()
},
Ruleset = r.Find<RulesetInfo>("mania")
Ruleset = r.Find<RulesetInfo>("mania")!
})));
AddUntilStep("2 panels visible", () => this.ChildrenOfType<ModPresetPanel>().Count() == 2);
@ -115,7 +115,7 @@ namespace osu.Game.Tests.Visual.UserInterface
new OsuModHidden(),
new OsuModHardRock()
},
Ruleset = r.Find<RulesetInfo>("osu")
Ruleset = r.Find<RulesetInfo>("osu")!
})));
AddUntilStep("2 panels visible", () => this.ChildrenOfType<ModPresetPanel>().Count() == 2);

View File

@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
Name = "AR0",
Description = "Too... many... circles...",
Ruleset = r.Find<RulesetInfo>(OsuRuleset.SHORT_NAME),
Ruleset = r.Find<RulesetInfo>(OsuRuleset.SHORT_NAME)!,
Mods = new[]
{
new OsuModDifficultyAdjust
@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
Name = "Half Time 0.5x",
Description = "Very slow",
Ruleset = r.Find<RulesetInfo>(OsuRuleset.SHORT_NAME),
Ruleset = r.Find<RulesetInfo>(OsuRuleset.SHORT_NAME)!,
Mods = new[]
{
new OsuModHalfTime

View File

@ -9,7 +9,6 @@ using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input;
@ -94,7 +93,7 @@ namespace osu.Game.Tournament
Task.Run(readBracket);
}
private void readBracket()
private async Task readBracket()
{
try
{
@ -102,7 +101,7 @@ namespace osu.Game.Tournament
{
using (Stream stream = storage.GetStream(BRACKET_FILENAME, FileAccess.Read, FileMode.Open))
using (var sr = new StreamReader(stream))
ladder = JsonConvert.DeserializeObject<LadderInfo>(sr.ReadToEnd(), new JsonPointConverter());
ladder = JsonConvert.DeserializeObject<LadderInfo>(await sr.ReadToEndAsync().ConfigureAwait(false), new JsonPointConverter());
}
ladder ??= new LadderInfo();
@ -166,8 +165,8 @@ namespace osu.Game.Tournament
}
addedInfo |= addPlayers();
addedInfo |= addRoundBeatmaps();
addedInfo |= addSeedingBeatmaps();
addedInfo |= await addRoundBeatmaps().ConfigureAwait(false);
addedInfo |= await addSeedingBeatmaps().ConfigureAwait(false);
if (addedInfo)
saveChanges();
@ -233,7 +232,7 @@ namespace osu.Game.Tournament
/// <summary>
/// Add missing beatmap info based on beatmap IDs
/// </summary>
private bool addRoundBeatmaps()
private async Task<bool> addRoundBeatmaps()
{
var beatmapsRequiringPopulation = ladder.Rounds
.SelectMany(r => r.Beatmaps)
@ -246,7 +245,7 @@ namespace osu.Game.Tournament
{
var b = beatmapsRequiringPopulation[i];
b.Beatmap = new TournamentBeatmap(beatmapCache.GetBeatmapAsync(b.ID).GetResultSafely() ?? new APIBeatmap());
b.Beatmap = new TournamentBeatmap(await beatmapCache.GetBeatmapAsync(b.ID).ConfigureAwait(false) ?? new APIBeatmap());
updateLoadProgressMessage($"Populating round beatmaps ({i} / {beatmapsRequiringPopulation.Count})");
}
@ -257,7 +256,7 @@ namespace osu.Game.Tournament
/// <summary>
/// Add missing beatmap info based on beatmap IDs
/// </summary>
private bool addSeedingBeatmaps()
private async Task<bool> addSeedingBeatmaps()
{
var beatmapsRequiringPopulation = ladder.Teams
.SelectMany(r => r.SeedingResults)
@ -271,7 +270,7 @@ namespace osu.Game.Tournament
{
var b = beatmapsRequiringPopulation[i];
b.Beatmap = new TournamentBeatmap(beatmapCache.GetBeatmapAsync(b.ID).GetResultSafely() ?? new APIBeatmap());
b.Beatmap = new TournamentBeatmap(await beatmapCache.GetBeatmapAsync(b.ID).ConfigureAwait(false) ?? new APIBeatmap());
updateLoadProgressMessage($"Populating seeding beatmaps ({i} / {beatmapsRequiringPopulation.Count})");
}

View File

@ -14,8 +14,11 @@ using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play;
namespace osu.Game
@ -25,6 +28,9 @@ namespace osu.Game
[Resolved]
private RulesetStore rulesetStore { get; set; } = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
@ -40,19 +46,23 @@ namespace osu.Game
[Resolved]
private ILocalUserPlayInfo? localUserPlayInfo { get; set; }
[Resolved]
private INotificationOverlay? notificationOverlay { get; set; }
protected virtual int TimeToSleepDuringGameplay => 30000;
protected override void LoadComplete()
{
base.LoadComplete();
Task.Run(() =>
Task.Factory.StartNew(() =>
{
Logger.Log("Beginning background beatmap processing..");
checkForOutdatedStarRatings();
processBeatmapSetsWithMissingMetrics();
processScoresWithMissingStatistics();
}).ContinueWith(t =>
convertLegacyTotalScoreToStandardised();
}, TaskCreationOptions.LongRunning).ContinueWith(t =>
{
if (t.Exception?.InnerException is ObjectDisposedException)
{
@ -92,7 +102,7 @@ namespace osu.Game
}
}
r.Find<RulesetInfo>(ruleset.ShortName).LastAppliedDifficultyVersion = currentVersion;
r.Find<RulesetInfo>(ruleset.ShortName)!.LastAppliedDifficultyVersion = currentVersion;
});
Logger.Log($"Finished resetting {countReset} beatmap sets for {ruleset.Name}");
@ -121,11 +131,7 @@ namespace osu.Game
foreach (var id in beatmapSetIds)
{
while (localUserPlayInfo?.IsPlaying.Value == true)
{
Logger.Log("Background processing sleeping due to active gameplay...");
Thread.Sleep(TimeToSleepDuringGameplay);
}
sleepIfRequired();
realmAccess.Run(r =>
{
@ -157,20 +163,20 @@ namespace osu.Game
{
foreach (var score in r.All<ScoreInfo>())
{
if (score.Statistics.Sum(kvp => kvp.Value) > 0 && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0)
if (score.BeatmapInfo != null
&& score.Statistics.Sum(kvp => kvp.Value) > 0
&& score.MaximumStatistics.Sum(kvp => kvp.Value) == 0)
{
scoreIds.Add(score.ID);
}
}
});
Logger.Log($"Found {scoreIds.Count} scores which require reprocessing.");
foreach (var id in scoreIds)
{
while (localUserPlayInfo?.IsPlaying.Value == true)
{
Logger.Log("Background processing sleeping due to active gameplay...");
Thread.Sleep(TimeToSleepDuringGameplay);
}
sleepIfRequired();
try
{
@ -182,16 +188,105 @@ namespace osu.Game
// ReSharper disable once MethodHasAsyncOverload
realmAccess.Write(r =>
{
r.Find<ScoreInfo>(id).MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics);
r.Find<ScoreInfo>(id)!.MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics);
});
Logger.Log($"Populated maximum statistics for score {id}");
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception e)
{
Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}");
}
}
}
private void convertLegacyTotalScoreToStandardised()
{
Logger.Log("Querying for scores that need total score conversion...");
HashSet<Guid> scoreIds = realmAccess.Run(r => new HashSet<Guid>(r.All<ScoreInfo>()
.Where(s => s.BeatmapInfo != null && s.TotalScoreVersion == 30000002)
.AsEnumerable().Select(s => s.ID)));
Logger.Log($"Found {scoreIds.Count} scores which require total score conversion.");
if (scoreIds.Count == 0)
return;
ProgressNotification notification = new ProgressNotification { State = ProgressNotificationState.Active };
notificationOverlay?.Post(notification);
int processedCount = 0;
int failedCount = 0;
foreach (var id in scoreIds)
{
if (notification.State == ProgressNotificationState.Cancelled)
break;
notification.Text = $"Upgrading scores to new scoring algorithm ({processedCount} of {scoreIds.Count})";
notification.Progress = (float)processedCount / scoreIds.Count;
sleepIfRequired();
try
{
var score = scoreManager.Query(s => s.ID == id);
long newTotalScore = StandardisedScoreMigrationTools.ConvertFromLegacyTotalScore(score, beatmapManager);
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
realmAccess.Write(r =>
{
ScoreInfo s = r.Find<ScoreInfo>(id)!;
s.TotalScore = newTotalScore;
s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
});
Logger.Log($"Converted total score for score {id}");
++processedCount;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception e)
{
Logger.Log($"Failed to convert total score for {id}: {e}");
++failedCount;
}
}
if (processedCount == scoreIds.Count)
{
notification.CompletionText = $"{processedCount} score(s) have been upgraded to the new scoring algorithm";
notification.Progress = 1;
notification.State = ProgressNotificationState.Completed;
}
else
{
notification.Text = $"{processedCount} of {scoreIds.Count} score(s) have been upgraded to the new scoring algorithm.";
// We may have arrived here due to user cancellation or completion with failures.
if (failedCount > 0)
notification.Text += $" Check logs for issues with {failedCount} failed upgrades.";
notification.State = ProgressNotificationState.Cancelled;
}
}
private void sleepIfRequired()
{
while (localUserPlayInfo?.IsPlaying.Value == true)
{
Logger.Log("Background processing sleeping due to active gameplay...");
Thread.Sleep(TimeToSleepDuringGameplay);
}
}
}
}

View File

@ -68,7 +68,7 @@ namespace osu.Game.Beatmaps
Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database);
original = realm.Find<BeatmapSetInfo>(original.ID);
original = realm!.Find<BeatmapSetInfo>(original.ID)!;
// Generally the import process will do this for us if the OnlineIDs match,
// but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated).
@ -204,6 +204,14 @@ namespace osu.Game.Beatmaps
protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters)
{
base.PostImport(model, realm, parameters);
// Scores are stored separately from beatmaps, and persist even when a beatmap is modified or deleted.
// Let's reattach any matching scores that exist in the database, based on hash.
foreach (BeatmapInfo beatmap in model.Beatmaps)
{
beatmap.UpdateLocalScores(realm);
}
ProcessBeatmap?.Invoke(model, parameters.Batch ? MetadataLookupScope.LocalCacheFirst : MetadataLookupScope.OnlineFirst);
}

View File

@ -234,6 +234,22 @@ namespace osu.Game.Beatmaps
}
}
/// <summary>
/// Local scores are retained separate from a beatmap's lifetime, matched via <see cref="ScoreInfo.BeatmapHash"/>.
/// Therefore we need to detach / reattach scores when a beatmap is edited or imported.
/// </summary>
/// <param name="realm">A realm instance in an active write transaction.</param>
public void UpdateLocalScores(Realm realm)
{
// first disassociate any scores which are already attached and no longer valid.
foreach (var score in Scores)
score.BeatmapInfo = null;
// then attach any scores which match the new hash.
foreach (var score in realm.All<ScoreInfo>().Where(s => s.BeatmapHash == Hash))
score.BeatmapInfo = this;
}
IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata;
IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet;
IRulesetInfo IBeatmapInfo.Ruleset => Ruleset;

View File

@ -208,7 +208,7 @@ namespace osu.Game.Beatmaps
using (var transaction = r.BeginWrite())
{
if (!beatmapInfo.IsManaged)
beatmapInfo = r.Find<BeatmapInfo>(beatmapInfo.ID);
beatmapInfo = r.Find<BeatmapInfo>(beatmapInfo.ID)!;
beatmapInfo.Hidden = true;
transaction.Commit();
@ -227,7 +227,7 @@ namespace osu.Game.Beatmaps
using (var transaction = r.BeginWrite())
{
if (!beatmapInfo.IsManaged)
beatmapInfo = r.Find<BeatmapInfo>(beatmapInfo.ID);
beatmapInfo = r.Find<BeatmapInfo>(beatmapInfo.ID)!;
beatmapInfo.Hidden = false;
transaction.Commit();
@ -330,7 +330,7 @@ namespace osu.Game.Beatmaps
Realm.Write(r =>
{
if (!beatmapInfo.IsManaged)
beatmapInfo = r.Find<BeatmapInfo>(beatmapInfo.ID);
beatmapInfo = r.Find<BeatmapInfo>(beatmapInfo.ID)!;
Debug.Assert(beatmapInfo.BeatmapSet != null);
Debug.Assert(beatmapInfo.File != null);
@ -460,13 +460,16 @@ namespace osu.Game.Beatmaps
Realm.Write(r =>
{
var liveBeatmapSet = r.Find<BeatmapSetInfo>(setInfo.ID);
var liveBeatmapSet = r.Find<BeatmapSetInfo>(setInfo.ID)!;
setInfo.CopyChangesToRealm(liveBeatmapSet);
if (transferCollections)
beatmapInfo.TransferCollectionReferences(r, oldMd5Hash);
liveBeatmapSet.Beatmaps.Single(b => b.ID == beatmapInfo.ID)
.UpdateLocalScores(r);
// do not look up metadata.
// this is a locally-modified set now, so looking up metadata is busy work at best and harmful at worst.
ProcessBeatmap?.Invoke(liveBeatmapSet, MetadataLookupScope.None);

View File

@ -52,7 +52,7 @@ namespace osu.Game.Beatmaps
/// </summary>
/// <param name="beatmapSet">The managed beatmap set to update. A transaction will be opened to apply changes.</param>
/// <param name="lookupScope">The preferred scope to use for metadata lookup.</param>
public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm.Write(r =>
public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm!.Write(_ =>
{
// Before we use below, we want to invalidate.
workingBeatmapCache.Invalidate(beatmapSet);

View File

@ -59,7 +59,7 @@ namespace osu.Game.Collections
Current.BindValueChanged(selectionChanged);
}
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes, Exception error)
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes)
{
var selectedItem = SelectedItem?.Value?.Collection;

View File

@ -41,7 +41,7 @@ namespace osu.Game.Collections
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>().OrderBy(c => c.Name), collectionsChanged);
}
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes, Exception error)
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes)
{
Items.Clear();
Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm)));

View File

@ -197,7 +197,7 @@ namespace osu.Game.Collections
return true;
}
private void deleteCollection() => collection.PerformWrite(c => c.Realm.Remove(c));
private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c));
}
}
}

View File

@ -129,6 +129,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true);
SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true);
SetDefault(OsuSetting.KeyOverlay, false);
SetDefault(OsuSetting.ReplaySettingsOverlay, true);
SetDefault(OsuSetting.GameplayLeaderboard, true);
SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true);
@ -382,6 +383,7 @@ namespace osu.Game.Configuration
SafeAreaConsiderations,
ComboColourNormalisationAmount,
ProfileCoverExpanded,
EditorLimitedDistanceSnap
EditorLimitedDistanceSnap,
ReplaySettingsOverlay
}
}

View File

@ -19,8 +19,8 @@ namespace osu.Game.Database
IEnumerator IEnumerable.GetEnumerator() => emptySet.GetEnumerator();
public int Count => emptySet.Count;
public T this[int index] => emptySet[index];
public int IndexOf(object item) => emptySet.IndexOf((T)item);
public bool Contains(object item) => emptySet.Contains((T)item);
public int IndexOf(object? item) => item == null ? -1 : emptySet.IndexOf((T)item);
public bool Contains(object? item) => item != null && emptySet.Contains((T)item);
public event NotifyCollectionChangedEventHandler? CollectionChanged
{

View File

@ -34,13 +34,13 @@ namespace osu.Game.Database
}
public void DeleteFile(TModel item, RealmNamedFileUsage file) =>
performFileOperation(item, managed => DeleteFile(managed, managed.Files.First(f => f.Filename == file.Filename), managed.Realm));
performFileOperation(item, managed => DeleteFile(managed, managed.Files.First(f => f.Filename == file.Filename), managed.Realm!));
public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents) =>
performFileOperation(item, managed => ReplaceFile(file, contents, managed.Realm));
performFileOperation(item, managed => ReplaceFile(file, contents, managed.Realm!));
public void AddFile(TModel item, Stream contents, string filename) =>
performFileOperation(item, managed => AddFile(managed, contents, filename, managed.Realm));
performFileOperation(item, managed => AddFile(managed, contents, filename, managed.Realm!));
private void performFileOperation(TModel item, Action<TModel> operation)
{
@ -178,13 +178,14 @@ namespace osu.Game.Database
// (ie. if an async import finished very recently).
return Realm.Write(realm =>
{
if (!item.IsManaged)
item = realm.Find<TModel>(item.ID);
TModel? processableItem = item;
if (!processableItem.IsManaged)
processableItem = realm.Find<TModel>(item.ID);
if (item?.DeletePending != false)
if (processableItem?.DeletePending != false)
return false;
item.DeletePending = true;
processableItem.DeletePending = true;
return true;
});
}
@ -195,13 +196,14 @@ namespace osu.Game.Database
// (ie. if an async import finished very recently).
Realm.Write(realm =>
{
if (!item.IsManaged)
item = realm.Find<TModel>(item.ID);
TModel? processableItem = item;
if (!processableItem.IsManaged)
processableItem = realm.Find<TModel>(item.ID);
if (item?.DeletePending != true)
if (processableItem?.DeletePending != true)
return;
item.DeletePending = false;
processableItem.DeletePending = false;
});
}

View File

@ -78,8 +78,9 @@ namespace osu.Game.Database
/// 28 2023-06-08 Added IsLegacyScore to ScoreInfo, parsed from replay files.
/// 29 2023-06-12 Run migration of old lazer scores to be best-effort in the new scoring number space. No actual realm changes.
/// 30 2023-06-16 Run migration of old lazer scores again. This time with more correct rounding considerations.
/// 31 2023-06-26 Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores.
/// </summary>
private const int schema_version = 30;
private const int schema_version = 31;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -534,7 +535,7 @@ namespace osu.Game.Database
lock (notificationsResetMap)
{
// Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing.
notificationsResetMap.Add(action, () => callback(new EmptyRealmSet<T>(), null, null));
notificationsResetMap.Add(action, () => callback(new EmptyRealmSet<T>(), null));
}
return RegisterCustomSubscription(action);
@ -754,10 +755,10 @@ namespace osu.Game.Database
for (int i = 0; i < itemCount; i++)
{
dynamic? oldItem = oldItems.ElementAt(i);
dynamic? newItem = newItems.ElementAt(i);
dynamic oldItem = oldItems.ElementAt(i);
dynamic newItem = newItems.ElementAt(i);
long? nullableOnlineID = oldItem?.OnlineID;
long? nullableOnlineID = oldItem.OnlineID;
newItem.OnlineID = (int)(nullableOnlineID ?? -1);
}
}
@ -794,7 +795,7 @@ namespace osu.Game.Database
for (int i = 0; i < metadataCount; i++)
{
dynamic? oldItem = oldMetadata.ElementAt(i);
dynamic oldItem = oldMetadata.ElementAt(i);
var newItem = newMetadata.ElementAt(i);
string username = oldItem.Author;
@ -817,7 +818,7 @@ namespace osu.Game.Database
for (int i = 0; i < newSettings.Count; i++)
{
dynamic? oldItem = oldSettings.ElementAt(i);
dynamic oldItem = oldSettings.ElementAt(i);
var newItem = newSettings.ElementAt(i);
long rulesetId = oldItem.RulesetID;
@ -842,7 +843,7 @@ namespace osu.Game.Database
for (int i = 0; i < newKeyBindings.Count; i++)
{
dynamic? oldItem = oldKeyBindings.ElementAt(i);
dynamic oldItem = oldKeyBindings.ElementAt(i);
var newItem = newKeyBindings.ElementAt(i);
if (oldItem.RulesetID == null)
@ -896,7 +897,7 @@ namespace osu.Game.Database
var scores = migration.NewRealm.All<ScoreInfo>();
foreach (var score in scores)
score.BeatmapHash = score.BeatmapInfo.Hash;
score.BeatmapHash = score.BeatmapInfo?.Hash ?? string.Empty;
break;
}
@ -966,6 +967,25 @@ namespace osu.Game.Database
break;
}
case 31:
{
foreach (var score in migration.NewRealm.All<ScoreInfo>())
{
if (score.IsLegacyScore && score.Ruleset.IsLegacyRuleset())
{
// Scores with this version will trigger the score upgrade process in BackgroundBeatmapProcessor.
score.TotalScoreVersion = 30000002;
// Transfer known legacy scores to a permanent storage field for preservation.
score.LegacyTotalScore = score.TotalScore;
}
else
score.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
}
break;
}
}
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

View File

@ -4,6 +4,7 @@
using System;
using System.Diagnostics;
using osu.Framework.Development;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Statistics;
using Realms;
@ -104,7 +105,7 @@ namespace osu.Game.Database
PerformRead(t =>
{
using (var transaction = t.Realm.BeginWrite())
using (var transaction = t.Realm!.BeginWrite())
{
perform(t);
transaction.Commit();
@ -133,7 +134,7 @@ namespace osu.Game.Database
{
Debug.Assert(ThreadSafety.IsUpdateThread);
if (dataIsFromUpdateThread && !data.Realm.IsClosed)
if (dataIsFromUpdateThread && !data.Realm.AsNonNull().IsClosed)
{
RealmLiveStatistics.USAGE_UPDATE_IMMEDIATE.Value++;
return;
@ -154,7 +155,7 @@ namespace osu.Game.Database
// To ensure that behaviour matches what we'd expect (the object *is* available), force
// a refresh to bring in any off-thread changes immediately.
realm.Refresh();
found = realm.Find<T>(ID);
found = realm.Find<T>(ID)!;
}
return found;

View File

@ -43,7 +43,7 @@ namespace osu.Game.Database
.ForMember(s => s.BeatmapSet, cc => cc.Ignore())
.AfterMap((s, d) =>
{
d.Ruleset = d.Realm.Find<RulesetInfo>(s.Ruleset.ShortName);
d.Ruleset = d.Realm!.Find<RulesetInfo>(s.Ruleset.ShortName)!;
copyChangesToRealm(s.Difficulty, d.Difficulty);
copyChangesToRealm(s.Metadata, d.Metadata);
});
@ -57,7 +57,7 @@ namespace osu.Game.Database
// Importantly, search all of realm for the beatmap (not just the set's beatmaps).
// It may have gotten detached, and if that's the case let's use this opportunity to fix
// things up.
var existingBeatmap = d.Realm.Find<BeatmapInfo>(beatmap.ID);
var existingBeatmap = d.Realm!.Find<BeatmapInfo>(beatmap.ID);
if (existingBeatmap != null)
{
@ -77,7 +77,7 @@ namespace osu.Game.Database
{
ID = beatmap.ID,
BeatmapSet = d,
Ruleset = d.Realm.Find<RulesetInfo>(beatmap.Ruleset.ShortName)
Ruleset = d.Realm.Find<RulesetInfo>(beatmap.Ruleset.ShortName)!
};
d.Beatmaps.Add(newBeatmap);
@ -282,12 +282,10 @@ namespace osu.Game.Database
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="M:System.IDisposable.Dispose" />.
///
/// May be null in the case the provided collection is not managed.
/// </returns>
/// <seealso cref="M:Realms.CollectionExtensions.SubscribeForNotifications``1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0})" />
/// <seealso cref="M:Realms.CollectionExtensions.SubscribeForNotifications``1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0})" />
public static IDisposable? QueryAsyncWithNotifications<T>(this IRealmCollection<T> collection, NotificationCallbackDelegate<T> callback)
public static IDisposable QueryAsyncWithNotifications<T>(this IRealmCollection<T> collection, NotificationCallbackDelegate<T> callback)
where T : RealmObjectBase
{
if (!RealmAccess.CurrentThreadSubscriptionsAllowed)

View File

@ -3,8 +3,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
@ -185,6 +188,100 @@ namespace osu.Game.Database
return (long)Math.Round((1000000 * (accuracyPortion * accuracyScore + (1 - accuracyPortion) * comboScore) + bonusScore) * modMultiplier);
}
/// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary>
/// <param name="score">The score to convert the total score of.</param>
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
/// <returns>The standardised total score.</returns>
public static long ConvertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps)
{
if (!score.IsLegacyScore)
return score.TotalScore;
WorkingBeatmap beatmap = beatmaps.GetWorkingBeatmap(score.BeatmapInfo);
Ruleset ruleset = score.Ruleset.CreateInstance();
if (ruleset is not ILegacyRuleset legacyRuleset)
return score.TotalScore;
var playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, score.Mods);
if (playableBeatmap.HitObjects.Count == 0)
throw new InvalidOperationException("Beatmap contains no hit objects!");
ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator();
sv1Simulator.Simulate(beatmap, playableBeatmap, score.Mods);
return ConvertFromLegacyTotalScore(score, new DifficultyAttributes
{
LegacyAccuracyScore = sv1Simulator.AccuracyScore,
LegacyComboScore = sv1Simulator.ComboScore,
LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio
});
}
/// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary>
/// <param name="score">The score to convert the total score of.</param>
/// <param name="attributes">Difficulty attributes providing the legacy scoring values
/// (<see cref="DifficultyAttributes.LegacyAccuracyScore"/>, <see cref="DifficultyAttributes.LegacyComboScore"/>, and <see cref="DifficultyAttributes.LegacyBonusScoreRatio"/>)
/// for the beatmap which the score was set on.</param>
/// <returns>The standardised total score.</returns>
public static long ConvertFromLegacyTotalScore(ScoreInfo score, DifficultyAttributes attributes)
{
if (!score.IsLegacyScore)
return score.TotalScore;
Debug.Assert(score.LegacyTotalScore != null);
int maximumLegacyAccuracyScore = attributes.LegacyAccuracyScore;
int maximumLegacyComboScore = attributes.LegacyComboScore;
double maximumLegacyBonusRatio = attributes.LegacyBonusScoreRatio;
double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n);
// The part of total score that doesn't include bonus.
int maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore;
// The combo proportion is calculated as a proportion of maximumLegacyBaseScore.
double comboProportion = Math.Min(1, (double)score.LegacyTotalScore / maximumLegacyBaseScore);
// The bonus proportion makes up the rest of the score that exceeds maximumLegacyBaseScore.
double bonusProportion = Math.Max(0, ((long)score.LegacyTotalScore - maximumLegacyBaseScore) * maximumLegacyBonusRatio);
switch (score.Ruleset.OnlineID)
{
case 0:
return (long)Math.Round((
700000 * comboProportion
+ 300000 * Math.Pow(score.Accuracy, 10)
+ bonusProportion) * modMultiplier);
case 1:
return (long)Math.Round((
250000 * comboProportion
+ 750000 * Math.Pow(score.Accuracy, 3.6)
+ bonusProportion) * modMultiplier);
case 2:
return (long)Math.Round((
600000 * comboProportion
+ 400000 * score.Accuracy
+ bonusProportion) * modMultiplier);
case 3:
return (long)Math.Round((
990000 * comboProportion
+ 10000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy)
+ bonusProportion) * modMultiplier);
default:
return score.TotalScore;
}
}
private class FakeHit : HitObject
{
private readonly Judgement judgement;

View File

@ -51,7 +51,7 @@ namespace osu.Game.Input.Bindings
protected override void LoadComplete()
{
realmSubscription = realm.RegisterForNotifications(queryRealmKeyBindings, (sender, _, _) =>
realmSubscription = realm.RegisterForNotifications(queryRealmKeyBindings, (sender, _) =>
{
// The first fire of this is a bit redundant as this is being called in base.LoadComplete,
// but this is safest in case the subscription is restored after a context recycle.

View File

@ -129,6 +129,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(InputKey.MouseMiddle, GlobalAction.TogglePauseReplay),
new KeyBinding(InputKey.Left, GlobalAction.SeekReplayBackward),
new KeyBinding(InputKey.Right, GlobalAction.SeekReplayForward),
new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.ToggleReplaySettings),
};
public IEnumerable<KeyBinding> SongSelectKeyBindings => new[]
@ -374,5 +375,8 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExportReplay))]
ExportReplay,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleReplaySettings))]
ToggleReplaySettings,
}
}

View File

@ -65,10 +65,15 @@ namespace osu.Game.Localisation
public static LocalisableString HUDVisibilityMode => new TranslatableString(getKey(@"hud_visibility_mode"), @"HUD overlay visibility mode");
/// <summary>
/// "Show health display even when you can't fail"
/// "Show health display even when you can&#39;t fail"
/// </summary>
public static LocalisableString ShowHealthDisplayWhenCantFail => new TranslatableString(getKey(@"show_health_display_when_cant_fail"), @"Show health display even when you can't fail");
/// <summary>
/// "Show replay settings overlay"
/// </summary>
public static LocalisableString ShowReplaySettingsOverlay => new TranslatableString(getKey(@"show_replay_settings_overlay"), @"Show replay settings overlay");
/// <summary>
/// "Fade playfield to red when health is low"
/// </summary>
@ -134,6 +139,6 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ClassicScoreDisplay => new TranslatableString(getKey(@"classic_score_display"), @"Classic");
private static string getKey(string key) => $"{prefix}:{key}";
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -324,6 +324,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ToggleChatFocus => new TranslatableString(getKey(@"toggle_chat_focus"), @"Toggle chat focus");
/// <summary>
/// "Toggle replay settings"
/// </summary>
public static LocalisableString ToggleReplaySettings => new TranslatableString(getKey(@"toggle_replay_settings"), @"Toggle replay settings");
/// <summary>
/// "Save replay"
/// </summary>

View File

@ -24,6 +24,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString SignedIn => new TranslatableString(getKey(@"signed_in"), @"Signed in");
/// <summary>
/// "Sign out"
/// </summary>
public static LocalisableString SignOut => new TranslatableString(getKey(@"sign_out"), @"Sign out");
/// <summary>
/// "Account"
/// </summary>

View File

@ -40,7 +40,7 @@ namespace osu.Game.Online
// Used to interact with manager classes that don't support interface types. Will eventually be replaced.
var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID };
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, _, _) =>
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, _) =>
{
if (items.Any())
Schedule(() => UpdateState(DownloadState.LocallyAvailable));

View File

@ -171,6 +171,8 @@ namespace osu.Game.Online.Chat
public abstract partial class HighlightMessageNotification : SimpleNotification
{
public override string PopInSampleName => "UI/notification-mention";
protected HighlightMessageNotification(Message message, Channel channel)
{
this.message = message;

View File

@ -29,7 +29,7 @@ namespace osu.Game.Online.Multiplayer
/// The availability state of the current beatmap.
/// </summary>
[Key(2)]
public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.LocallyAvailable();
public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.Unknown();
/// <summary>
/// Any mods applicable only to the local user.

View File

@ -34,6 +34,7 @@ namespace osu.Game.Online.Rooms
DownloadProgress = downloadProgress;
}
public static BeatmapAvailability Unknown() => new BeatmapAvailability(DownloadState.Unknown);
public static BeatmapAvailability NotDownloaded() => new BeatmapAvailability(DownloadState.NotDownloaded);
public static BeatmapAvailability Downloading(float progress) => new BeatmapAvailability(DownloadState.Downloading, progress);
public static BeatmapAvailability Importing() => new BeatmapAvailability(DownloadState.Importing);

View File

@ -60,6 +60,15 @@ namespace osu.Game.Online.Rooms
if (item.NewValue == null)
return;
// Initially set to unknown until we have attained a good state.
// This has the wanted side effect of forcing a state change when the current playlist
// item changes at the server but our local availability doesn't necessarily change
// (ie. we have both the previous and next item LocallyAvailable).
//
// Note that even without this, the server will trigger a state change and things will work.
// This is just for safety.
availability.Value = BeatmapAvailability.Unknown();
downloadTracker?.RemoveAndDisposeImmediately();
selectedBeatmap = null;
@ -98,7 +107,7 @@ namespace osu.Game.Online.Rooms
// handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow).
realmSubscription?.Dispose();
realmSubscription = realm.RegisterForNotifications(_ => filteredBeatmaps(), (_, changes, _) =>
realmSubscription = realm.RegisterForNotifications(_ => filteredBeatmaps(), (_, changes) =>
{
if (changes == null)
return;
@ -115,6 +124,9 @@ namespace osu.Game.Online.Rooms
switch (downloadTracker.State.Value)
{
case DownloadState.Unknown:
availability.Value = BeatmapAvailability.Unknown();
break;
case DownloadState.NotDownloaded:
availability.Value = BeatmapAvailability.NotDownloaded();
break;

View File

@ -48,7 +48,7 @@ namespace osu.Game.Online
realmSubscription = realm.RegisterForNotifications(r => r.All<ScoreInfo>().Where(s =>
((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID)
|| (!string.IsNullOrEmpty(s.Hash) && s.Hash == TrackedItem.Hash))
&& !s.DeletePending), (items, _, _) =>
&& !s.DeletePending), (items, _) =>
{
if (items.Any())
Schedule(() => UpdateState(DownloadState.LocallyAvailable));

View File

@ -185,7 +185,7 @@ namespace osu.Game.Online.Spectator
IsPlaying = true;
// transfer state at point of beginning play
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID;
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo!.OnlineID;
currentState.RulesetID = score.ScoreInfo.RulesetID;
currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray();
currentState.State = SpectatedUserState.Playing;

View File

@ -163,7 +163,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
},
username,
#pragma warning disable 618
new StatisticText(score.MaxCombo, score.BeatmapInfo.MaxCombo, @"0\x"),
new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"),
#pragma warning restore 618
};

View File

@ -123,7 +123,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
accuracyColumn.Text = value.DisplayAccuracy;
maxComboColumn.Text = value.MaxCombo.ToLocalisableString(@"0\x");
ppColumn.Alpha = value.BeatmapInfo.Status.GrantsPerformancePoints() ? 1 : 0;
ppColumn.Alpha = value.BeatmapInfo!.Status.GrantsPerformancePoints() ? 1 : 0;
if (value.PP is double pp)
ppColumn.Text = pp.ToLocalisableString(@"N0");

View File

@ -225,7 +225,12 @@ namespace osu.Game.Overlays.Dialog
/// <summary>
/// Programmatically clicks the first button of the provided type.
/// </summary>
public void PerformAction<T>() where T : PopupDialogButton => Buttons.OfType<T>().First().TriggerClick();
public void PerformAction<T>() where T : PopupDialogButton
{
// Buttons are regularly added in BDL or LoadComplete, so let's schedule to ensure
// they are ready to be pressed.
Schedule(() => Buttons.OfType<T>().First().TriggerClick());
}
protected override bool OnKeyDown(KeyDownEvent e)
{

View File

@ -123,7 +123,7 @@ namespace osu.Game.Overlays.FirstRunSetup
beatmapSubscription?.Dispose();
}
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error) => Schedule(() =>
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes) => Schedule(() =>
{
currentlyLoadedBeatmaps.Text = FirstRunSetupBeatmapScreenStrings.CurrentlyLoadedBeatmaps(sender.Count);

View File

@ -44,6 +44,6 @@ namespace osu.Game.Overlays
/// <summary>
/// All ongoing operations (ie. any <see cref="ProgressNotification"/> not in a completed state).
/// </summary>
public IEnumerable<ProgressNotification> OngoingOperations => AllNotifications.OfType<ProgressNotification>().Where(p => p.State != ProgressNotificationState.Completed);
public IEnumerable<ProgressNotification> OngoingOperations => AllNotifications.OfType<ProgressNotification>().Where(p => p.State != ProgressNotificationState.Completed && p.State != ProgressNotificationState.Cancelled);
}
}

View File

@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Login
[LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.AppearOffline))]
AppearOffline,
[LocalisableDescription(typeof(UserVerificationStrings), nameof(UserVerificationStrings.BoxInfoLogoutLink))]
[LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.SignOut))]
SignOut,
}
}

View File

@ -102,7 +102,7 @@ namespace osu.Game.Overlays.Mods
Name = nameTextBox.Current.Value,
Description = descriptionTextBox.Current.Value,
Mods = selectedMods.Value.ToArray(),
Ruleset = r.Find<RulesetInfo>(ruleset.Value.ShortName)
Ruleset = r.Find<RulesetInfo>(ruleset.Value.ShortName)!
}));
this.HidePopover();

View File

@ -61,7 +61,7 @@ namespace osu.Game.Overlays.Mods
private Task? latestLoadTask;
internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true;
private void asyncLoadPanels(IRealmCollection<ModPreset> presets, ChangeSet changes, Exception error)
private void asyncLoadPanels(IRealmCollection<ModPreset> presets, ChangeSet? changes)
{
cancellationTokenSource?.Cancel();

View File

@ -109,7 +109,7 @@ namespace osu.Game.Overlays.Music
beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true);
}
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes, Exception error)
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet changes)
{
if (changes == null)
{

View File

@ -169,7 +169,7 @@ namespace osu.Game.Overlays
Logger.Log($"⚠️ {notification.Text}");
notification.Closed += notificationClosed;
notification.Closed += () => notificationClosed(notification);
if (notification is IHasCompletionTarget hasCompletionTarget)
hasCompletionTarget.CompletionTarget = Post;
@ -229,17 +229,20 @@ namespace osu.Game.Overlays
mainContent.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In);
}
private void notificationClosed() => Schedule(() =>
private void notificationClosed(Notification notification) => Schedule(() =>
{
updateCounts();
// this debounce is currently shared between popin/popout sounds, which means one could potentially not play when the user is expecting it.
// popout is constant across all notification types, and should therefore be handled using playback concurrency instead, but seems broken at the moment.
playDebouncedSample("UI/overlay-pop-out");
playDebouncedSample(notification.PopOutSampleName);
});
private void playDebouncedSample(string sampleName)
{
if (string.IsNullOrEmpty(sampleName))
return;
if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME)
{
audio.Samples.Get(sampleName)?.Play();

View File

@ -50,7 +50,8 @@ namespace osu.Game.Overlays.Notifications
/// </summary>
public virtual bool DisplayOnTop => true;
public virtual string PopInSampleName => "UI/notification-pop-in";
public virtual string PopInSampleName => "UI/notification-default";
public virtual string PopOutSampleName => "UI/overlay-pop-out";
protected NotificationLight Light;

View File

@ -10,6 +10,8 @@ namespace osu.Game.Overlays.Notifications
{
public partial class ProgressCompletionNotification : SimpleNotification
{
public override string PopInSampleName => "UI/notification-done";
public ProgressCompletionNotification()
{
Icon = FontAwesome.Solid.Check;

View File

@ -4,6 +4,8 @@
using System;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
@ -27,6 +29,8 @@ namespace osu.Game.Overlays.Notifications
protected override bool AllowFlingDismiss => false;
public override string PopOutSampleName => State is ProgressNotificationState.Cancelled ? base.PopOutSampleName : "";
/// <summary>
/// The function to post completion notifications back to.
/// </summary>
@ -122,6 +126,7 @@ namespace osu.Game.Overlays.Notifications
cancellationTokenSource.Cancel();
IconContent.FadeColour(ColourInfo.GradientVertical(Color4.Gray, Color4.Gray.Lighten(0.5f)), colour_fade_duration);
cancelSample?.Play();
loadingSpinner.Hide();
var icon = new SpriteIcon
@ -190,6 +195,8 @@ namespace osu.Game.Overlays.Notifications
private LoadingSpinner loadingSpinner = null!;
private Sample? cancelSample;
private readonly TextFlowContainer textDrawable;
public ProgressNotification()
@ -217,7 +224,7 @@ namespace osu.Game.Overlays.Notifications
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
private void load(OsuColour colours, AudioManager audioManager)
{
colourQueued = colours.YellowDark;
colourActive = colours.Blue;
@ -236,6 +243,8 @@ namespace osu.Game.Overlays.Notifications
Size = new Vector2(loading_spinner_size),
}
});
cancelSample = audioManager.Samples.Get(@"UI/notification-cancel");
}
public override void Close(bool runFlingAnimation)

View File

@ -7,7 +7,7 @@ namespace osu.Game.Overlays.Notifications
{
public partial class SimpleErrorNotification : SimpleNotification
{
public override string PopInSampleName => "UI/error-notification-pop-in";
public override string PopInSampleName => "UI/notification-error";
public SimpleErrorNotification()
{

View File

@ -25,10 +25,9 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
},
new SettingsCheckbox
{
ClassicDefault = false,
LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail,
Current = config.GetBindable<bool>(OsuSetting.ShowHealthDisplayWhenCantFail),
Keywords = new[] { "hp", "bar" }
LabelText = GameplaySettingsStrings.ShowReplaySettingsOverlay,
Current = config.GetBindable<bool>(OsuSetting.ReplaySettingsOverlay),
Keywords = new[] { "hide" },
},
new SettingsCheckbox
{
@ -41,6 +40,13 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay
LabelText = GameplaySettingsStrings.AlwaysShowGameplayLeaderboard,
Current = config.GetBindable<bool>(OsuSetting.GameplayLeaderboard),
},
new SettingsCheckbox
{
ClassicDefault = false,
LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail,
Current = config.GetBindable<bool>(OsuSetting.ShowHealthDisplayWhenCantFail),
Keywords = new[] { "hp", "bar" }
},
};
}
}

View File

@ -440,7 +440,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input
}
private void updateStoreFromButton(KeyButton button) =>
realm.WriteAsync(r => r.Find<RealmKeyBinding>(button.KeyBinding.ID).KeyCombinationString = button.KeyBinding.KeyCombinationString);
realm.WriteAsync(r => r.Find<RealmKeyBinding>(button.KeyBinding.ID)!.KeyCombinationString = button.KeyBinding.KeyCombinationString);
private void updateIsDefaultValue()
{

View File

@ -92,7 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections
});
}
private void skinsChanged(IRealmCollection<SkinInfo> sender, ChangeSet changes, Exception error)
private void skinsChanged(IRealmCollection<SkinInfo> sender, ChangeSet changes)
{
// This can only mean that realm is recycling, else we would see the protected skins.
// Because we are using `Live<>` in this class, we don't need to worry about this scenario too much.

View File

@ -3,10 +3,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Difficulty
{
@ -27,6 +27,9 @@ namespace osu.Game.Rulesets.Difficulty
protected const int ATTRIB_ID_FLASHLIGHT = 17;
protected const int ATTRIB_ID_SLIDER_FACTOR = 19;
protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21;
protected const int ATTRIB_ID_LEGACY_ACCURACY_SCORE = 23;
protected const int ATTRIB_ID_LEGACY_COMBO_SCORE = 25;
protected const int ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO = 27;
/// <summary>
/// The mods which were applied to the beatmap.
@ -45,6 +48,22 @@ namespace osu.Game.Rulesets.Difficulty
[JsonProperty("max_combo", Order = -2)]
public int MaxCombo { get; set; }
/// <summary>
/// The accuracy portion of the legacy (ScoreV1) total score.
/// </summary>
public int LegacyAccuracyScore { get; set; }
/// <summary>
/// The combo-multiplied portion of the legacy (ScoreV1) total score.
/// </summary>
public int LegacyComboScore { get; set; }
/// <summary>
/// A ratio of <c>new_bonus_score / old_bonus_score</c> for converting the bonus score of legacy scores to the new scoring.
/// This is made up of all judgements that would be <see cref="HitResult.SmallBonus"/> or <see cref="HitResult.LargeBonus"/>.
/// </summary>
public double LegacyBonusScoreRatio { get; set; }
/// <summary>
/// Creates new <see cref="DifficultyAttributes"/>.
/// </summary>
@ -69,7 +88,13 @@ namespace osu.Game.Rulesets.Difficulty
/// <remarks>
/// See: osu_difficulty_attribs table.
/// </remarks>
public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() => Enumerable.Empty<(int, object)>();
public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_LEGACY_ACCURACY_SCORE, LegacyAccuracyScore);
yield return (ATTRIB_ID_LEGACY_COMBO_SCORE, LegacyComboScore);
yield return (ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO, LegacyBonusScoreRatio);
}
/// <summary>
/// Reads osu-web database attribute mappings into this <see cref="DifficultyAttributes"/> object.
@ -78,6 +103,10 @@ namespace osu.Game.Rulesets.Difficulty
/// <param name="onlineInfo">The <see cref="IBeatmapOnlineInfo"/> where more information about the beatmap may be extracted from (such as AR/CS/OD/etc).</param>
public virtual void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
{
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
LegacyAccuracyScore = (int)values[ATTRIB_ID_LEGACY_ACCURACY_SCORE];
LegacyComboScore = (int)values[ATTRIB_ID_LEGACY_COMBO_SCORE];
LegacyBonusScoreRatio = (int)values[ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO];
}
}
}

View File

@ -23,6 +23,13 @@ namespace osu.Game.Rulesets.Difficulty
{
public abstract class DifficultyCalculator
{
/// <summary>
/// Whether legacy scoring values (ScoreV1) should be computed to populate the difficulty attributes
/// <see cref="DifficultyAttributes.LegacyAccuracyScore"/>, <see cref="DifficultyAttributes.LegacyComboScore"/>,
/// and <see cref="DifficultyAttributes.LegacyBonusScoreRatio"/>.
/// </summary>
public bool ComputeLegacyScoringValues;
/// <summary>
/// The beatmap for which difficulty will be calculated.
/// </summary>

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets
{
public interface ILegacyRuleset
@ -11,5 +13,7 @@ namespace osu.Game.Rulesets
/// Identifies the server-side ID of a legacy ruleset.
/// </summary>
int LegacyID { get; }
ILegacyScoreSimulator CreateLegacyScoreSimulator();
}
}

View File

@ -98,9 +98,9 @@ namespace osu.Game.Rulesets.Objects.Drawables
public virtual bool DisplayResult => true;
/// <summary>
/// Whether this <see cref="DrawableHitObject"/> and all of its nested <see cref="DrawableHitObject"/>s have been judged.
/// The scoring result of this <see cref="DrawableHitObject"/>.
/// </summary>
public bool AllJudged => Judged && NestedHitObjects.All(h => h.AllJudged);
public JudgementResult Result => Entry?.Result;
/// <summary>
/// Whether this <see cref="DrawableHitObject"/> has been hit. This occurs if <see cref="Result"/> is hit.
@ -112,12 +112,12 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// Whether this <see cref="DrawableHitObject"/> has been judged.
/// Note: This does NOT include nested hitobjects.
/// </summary>
public bool Judged => Result?.HasResult ?? true;
public bool Judged => Entry?.Judged ?? false;
/// <summary>
/// The scoring result of this <see cref="DrawableHitObject"/>.
/// Whether this <see cref="DrawableHitObject"/> and all of its nested <see cref="DrawableHitObject"/>s have been judged.
/// </summary>
public JudgementResult Result => Entry?.Result;
public bool AllJudged => Entry?.AllJudged ?? false;
/// <summary>
/// The relative X position of this hit object for sample playback balance adjustment.
@ -218,6 +218,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
protected sealed override void OnApply(HitObjectLifetimeEntry entry)
{
Debug.Assert(Entry != null);
// LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset.
// We override this with DHO's InitialLifetimeOffset for a non-pooled DHO.
if (entry is SyntheticHitObjectEntry)
@ -247,6 +249,12 @@ namespace osu.Game.Rulesets.Objects.Drawables
drawableNested.ParentHitObject = this;
nestedHitObjects.Add(drawableNested);
// assume that synthetic entries are not pooled and therefore need to be managed from within the DHO.
// this is important for the correctness of value of flags such as `AllJudged`.
if (drawableNested.Entry is SyntheticHitObjectEntry syntheticNestedEntry)
Entry.NestedEntries.Add(syntheticNestedEntry);
AddNestedHitObject(drawableNested);
}
@ -290,6 +298,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
protected sealed override void OnFree(HitObjectLifetimeEntry entry)
{
Debug.Assert(Entry != null);
StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable);
if (HitObject is IHasComboInformation combo)
@ -318,6 +328,8 @@ namespace osu.Game.Rulesets.Objects.Drawables
}
nestedHitObjects.Clear();
// clean up synthetic entries manually added in `Apply()`.
Entry.NestedEntries.RemoveAll(nestedEntry => nestedEntry is SyntheticHitObjectEntry);
ClearNestedHitObjects();
HitObject.DefaultsApplied -= onDefaultsApplied;

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Performance;
using osu.Game.Rulesets.Judgements;
@ -19,12 +21,28 @@ namespace osu.Game.Rulesets.Objects
/// </summary>
public readonly HitObject HitObject;
/// <summary>
/// The list of <see cref="HitObjectLifetimeEntry"/> for the <see cref="HitObject"/>'s nested objects (if any).
/// </summary>
public List<HitObjectLifetimeEntry> NestedEntries { get; internal set; } = new List<HitObjectLifetimeEntry>();
/// <summary>
/// The result that <see cref="HitObject"/> was judged with.
/// This is set by the accompanying <see cref="DrawableHitObject"/>, and reused when required for rewinding.
/// </summary>
internal JudgementResult? Result;
/// <summary>
/// Whether <see cref="HitObject"/> has been judged.
/// Note: This does NOT include nested hitobjects.
/// </summary>
public bool Judged => Result?.HasResult ?? false;
/// <summary>
/// Whether <see cref="HitObject"/> and all of its nested objects have been judged.
/// </summary>
public bool AllJudged => Judged && NestedEntries.All(h => h.AllJudged);
private readonly IBindable<double> startTimeBindable = new BindableDouble();
internal event Action? RevertResult;

View File

@ -43,11 +43,6 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// </remarks>
private readonly Dictionary<HitObjectLifetimeEntry, HitObject> parentMap = new Dictionary<HitObjectLifetimeEntry, HitObject>();
/// <summary>
/// Stores the list of child entries for each hit object managed by this <see cref="HitObjectEntryManager"/>.
/// </summary>
private readonly Dictionary<HitObject, List<HitObjectLifetimeEntry>> childrenMap = new Dictionary<HitObject, List<HitObjectLifetimeEntry>>();
public void Add(HitObjectLifetimeEntry entry, HitObject? parent)
{
HitObject hitObject = entry.HitObject;
@ -57,22 +52,24 @@ namespace osu.Game.Rulesets.Objects.Pooling
// Add the entry.
entryMap[hitObject] = entry;
childrenMap[hitObject] = new List<HitObjectLifetimeEntry>();
// If the entry has a parent, set it and add the entry to the parent's children.
if (parent != null)
{
parentMap[entry] = parent;
if (childrenMap.TryGetValue(parent, out var parentChildEntries))
parentChildEntries.Add(entry);
if (entryMap.TryGetValue(parent, out var parentEntry))
parentEntry.NestedEntries.Add(entry);
}
hitObject.DefaultsApplied += onDefaultsApplied;
OnEntryAdded?.Invoke(entry, parent);
}
public void Remove(HitObjectLifetimeEntry entry)
public bool Remove(HitObjectLifetimeEntry entry)
{
if (entry is SyntheticHitObjectEntry)
return false;
HitObject hitObject = entry.HitObject;
if (!entryMap.ContainsKey(hitObject))
@ -81,18 +78,16 @@ namespace osu.Game.Rulesets.Objects.Pooling
entryMap.Remove(hitObject);
// If the entry has a parent, unset it and remove the entry from the parents' children.
if (parentMap.Remove(entry, out var parent) && childrenMap.TryGetValue(parent, out var parentChildEntries))
parentChildEntries.Remove(entry);
if (parentMap.Remove(entry, out var parent) && entryMap.TryGetValue(parent, out var parentEntry))
parentEntry.NestedEntries.Remove(entry);
// Remove all the entries' children.
if (childrenMap.Remove(hitObject, out var childEntries))
{
foreach (var childEntry in childEntries)
foreach (var childEntry in entry.NestedEntries)
Remove(childEntry);
}
hitObject.DefaultsApplied -= onDefaultsApplied;
OnEntryRemoved?.Invoke(entry, parent);
return true;
}
public bool TryGet(HitObject hitObject, [MaybeNullWhen(false)] out HitObjectLifetimeEntry entry)
@ -105,16 +100,16 @@ namespace osu.Game.Rulesets.Objects.Pooling
/// </summary>
private void onDefaultsApplied(HitObject hitObject)
{
if (!childrenMap.Remove(hitObject, out var childEntries))
if (!entryMap.TryGetValue(hitObject, out var entry))
return;
// Remove all the entries' children. At this point the parents' (this entries') children list has been removed from the map, so this does not cause upwards traversal.
foreach (var entry in childEntries)
Remove(entry);
// Replace the entire list rather than clearing to prevent circular traversal later.
var previousEntries = entry.NestedEntries;
entry.NestedEntries = new List<HitObjectLifetimeEntry>();
// The removed children list needs to be added back to the map for the entry to potentially receive children.
childEntries.Clear();
childrenMap[hitObject] = childEntries;
// Remove all the entries' children. At this point the parents' (this entries') children list has been reconstructed, so this does not cause upwards traversal.
foreach (var nested in previousEntries)
Remove(nested);
}
}
}

View File

@ -0,0 +1,40 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Scoring
{
/// <summary>
/// Generates attributes which are required to calculate old-style Score V1 scores.
/// </summary>
public interface ILegacyScoreSimulator
{
/// <summary>
/// The accuracy portion of the legacy (ScoreV1) total score.
/// </summary>
int AccuracyScore { get; }
/// <summary>
/// The combo-multiplied portion of the legacy (ScoreV1) total score.
/// </summary>
int ComboScore { get; }
/// <summary>
/// A ratio of <c>new_bonus_score / old_bonus_score</c> for converting the bonus score of legacy scores to the new scoring.
/// This is made up of all judgements that would be <see cref="HitResult.SmallBonus"/> or <see cref="HitResult.LargeBonus"/>.
/// </summary>
double BonusScoreRatio { get; }
/// <summary>
/// Performs the simulation, computing the maximum <see cref="AccuracyScore"/>, <see cref="ComboScore"/>,
/// and <see cref="BonusScoreRatio"/> achievable for the given beatmap.
/// </summary>
/// <param name="workingBeatmap">The working beatmap.</param>
/// <param name="playableBeatmap">A playable version of the beatmap for the ruleset.</param>
/// <param name="mods">The applied mods.</param>
void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods);
}
}

View File

@ -28,7 +28,7 @@ namespace osu.Game.Scoring
double? PP { get; }
IBeatmapInfo Beatmap { get; }
IBeatmapInfo? Beatmap { get; }
IRulesetInfo Ruleset { get; }

Some files were not shown because too many files have changed in this diff Show More