diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29cbdd2d37..0da1f9636b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,9 +79,14 @@ jobs: run: | # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround. # FIXME: Suppress warnings from templates project - dotnet codefilesanity | while read -r line; do - echo "::warning::$line" - done + exit_code=0 + while read -r line; do + if [[ ! -z "$line" ]]; then + echo "::error::$line" + exit_code=1 + fi + done <<< $(dotnet codefilesanity) + exit $exit_code # Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded. # - name: .NET Format (Dry Run) diff --git a/osu.Android.props b/osu.Android.props index 5a0e7479fa..956093b2ac 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,7 +52,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 5580358f89..2fab47f857 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -14,11 +14,11 @@ namespace osu.Game.Rulesets.Catch.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; - [TestCase(4.050601681491468d, "diffcalc-test")] + [TestCase(4.0505463516206195d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(5.169743871843191d, "diffcalc-test")] + [TestCase(5.1696411260785498d, "diffcalc-test")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new CatchModDoubleTime()); diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 9140e8afce..aee3268544 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -47,8 +47,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty { StarRating = skills[0].DifficultyValue() * star_scaling_factor, Mods = mods, - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)Math.Ceiling(getHitWindow300(mods) / clockRate), + GreatHitWindow = Math.Ceiling(getHitWindow300(mods) / clockRate), ScoreMultiplier = getScoreMultiplier(mods), MaxCombo = beatmap.HitObjects.Sum(h => h is HoldNote ? 2 : 1), Skills = skills diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 19881b5c33..15675e74d1 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.6634445062299665d, "diffcalc-test")] - [TestCase(1.0414203870195022d, "zero-length-sliders")] + [TestCase(6.5867229481955389d, "diffcalc-test")] + [TestCase(1.0416315570967911d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(8.3858089051603368d, "diffcalc-test")] - [TestCase(1.2723279173428435d, "zero-length-sliders")] + [TestCase(8.2730989071947896d, "diffcalc-test")] + [TestCase(1.2726413186221039d, "zero-length-sliders")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new OsuModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 9da583a073..52ab39cfbd 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -30,6 +30,9 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSpinnerRotation : TestSceneOsuPlayer { + private const double spinner_start_time = 100; + private const double spinner_duration = 6000; + [Resolved] private AudioManager audioManager { get; set; } @@ -77,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Tests double finalTrackerRotation = 0, trackerRotationTolerance = 0; double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; - addSeekStep(5000); + addSeekStep(spinner_start_time + 5000); AddStep("retrieve disc rotation", () => { finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; @@ -90,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); - addSeekStep(2500); + addSeekStep(spinner_start_time + 2500); AddAssert("disc rotation rewound", // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. // due to the exponential damping applied we're allowing a larger margin of error of about 10% @@ -102,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); - addSeekStep(5000); + addSeekStep(spinner_start_time + 5000); AddAssert("is disc rotation almost same", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); AddAssert("is symbol rotation almost same", @@ -140,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestSpinnerNormalBonusRewinding() { - addSeekStep(1000); + addSeekStep(spinner_start_time + 1000); AddAssert("player score matching expected bonus score", () => { @@ -201,24 +204,9 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0)); } - private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay - { - Frames = scoreReplay - .Frames - .Cast() - .Select(replayFrame => - { - var adjustedTime = replayFrame.Time * rate; - return new OsuReplayFrame(adjustedTime, replayFrame.Position, replayFrame.Actions.ToArray()); - }) - .Cast() - .ToList() - }; - private void addSeekStep(double time) { AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } @@ -241,7 +229,8 @@ namespace osu.Game.Rulesets.Osu.Tests new Spinner { Position = new Vector2(256, 192), - EndTime = 6000, + StartTime = spinner_start_time, + Duration = spinner_duration }, } }; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index ac77a93239..bd4c0f2ad5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -12,6 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public double FlashlightRating { get; set; } public double ApproachRate { get; set; } public double OverallDifficulty { get; set; } + public double DrainRate { get; set; } public int HitCircleCount { get; set; } public int SpinnerCount { get; set; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 76ea69e5c3..790aa0eb7d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -56,7 +56,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty double starRating = basePerformance > 0.00001 ? Math.Cbrt(1.12) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; - double preempt = (int)IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double drainRate = beatmap.Difficulty.DrainRate; int maxCombo = beatmap.HitObjects.Count; // Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above) @@ -74,6 +75,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty FlashlightRating = flashlightRating, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, OverallDifficulty = (80 - hitWindowGreat) / 6, + DrainRate = drainRate, MaxCombo = maxCombo, HitCircleCount = hitCirclesCount, SpinnerCount = spinnerCount, @@ -100,8 +102,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty HitWindows hitWindows = new OsuHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future - hitWindowGreat = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate; + hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; return new Skill[] { diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 9d5887c26e..4e4dbc02a1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -40,9 +40,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); - // Custom multipliers for NoFail and SpunOut. double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + // Custom multipliers for NoFail and SpunOut. if (mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * countMiss); @@ -114,9 +114,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. - if (mods.Any(h => h is OsuModHidden)) + if (mods.Any(m => m is OsuModBlinds)) + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * countMiss)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate); + else if (mods.Any(h => h is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + } aimValue *= approachRateBonus; @@ -153,11 +157,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; - if (mods.Any(m => m is OsuModHidden)) + if (mods.Any(m => m is OsuModBlinds)) + { + // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. + speedValue *= 1.12; + } + else if (mods.Any(m => m is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + } // Scale the speed value with accuracy and OD. speedValue *= (0.95 + Math.Pow(Attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(Attributes.OverallDifficulty, 8)) / 2); + // Scale the speed value with # of 50s to punish doubletapping. speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); @@ -189,8 +202,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); - if (mods.Any(m => m is OsuModHidden)) + // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. + if (mods.Any(m => m is OsuModBlinds)) + accuracyValue *= 1.14; + else if (mods.Any(m => m is OsuModHidden)) accuracyValue *= 1.08; + if (mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 16a18cbcb9..d8f4aa1229 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -22,17 +22,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { } - protected override double SkillMultiplier => 26.25; - protected override double StrainDecayBase => 0.15; + private double currentStrain = 1; - protected override double StrainValueOf(DifficultyHitObject current) + private double skillMultiplier => 26.25; + private double strainDecayBase => 0.15; + + private double strainValueOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; var osuCurrent = (OsuDifficultyHitObject)current; - double result = 0; + double aimStrain = 0; if (Previous.Count > 0) { @@ -46,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills Math.Max(osuPrevious.JumpDistance - scale, 0) * Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2) * Math.Max(osuCurrent.JumpDistance - scale, 0)); - result = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); + aimStrain = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); } } @@ -54,11 +56,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills double travelDistanceExp = applyDiminishingExp(osuCurrent.TravelDistance); return Math.Max( - result + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), + aimStrain + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), (Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / osuCurrent.StrainTime ); } private double applyDiminishingExp(double val) => Math.Pow(val, 0.99); + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double CalculateInitialStrain(double time) => currentStrain * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += strainValueOf(current) * skillMultiplier; + + return currentStrain; + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index abd900a80d..e3abe7d700 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -19,12 +19,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { } - protected override double SkillMultiplier => 0.15; - protected override double StrainDecayBase => 0.15; + private double skillMultiplier => 0.15; + private double strainDecayBase => 0.15; protected override double DecayWeight => 1.0; protected override int HistoryLength => 10; // Look back for 10 notes is added for the sake of flashlight calculations. + private double currentStrain = 1; - protected override double StrainValueOf(DifficultyHitObject current) + private double strainValueOf(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; @@ -62,5 +63,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills return Math.Pow(smallDistNerf * result, 2.0); } + + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double CalculateInitialStrain(double time) => currentStrain * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += strainValueOf(current) * skillMultiplier; + + return currentStrain; + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs index 7bcd867a9c..e47edc37cc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -10,7 +10,7 @@ using osu.Framework.Utils; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { - public abstract class OsuStrainSkill : StrainDecaySkill + public abstract class OsuStrainSkill : StrainSkill { /// /// The number of sections with the highest strains, which the peak strain reductions will apply to. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 9364b11048..cae6b8e01c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -16,19 +16,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills public class Speed : OsuStrainSkill { private const double single_spacing_threshold = 125; - - private const double angle_bonus_begin = 5 * Math.PI / 6; - private const double pi_over_4 = Math.PI / 4; - private const double pi_over_2 = Math.PI / 2; - - protected override double SkillMultiplier => 1400; - protected override double StrainDecayBase => 0.3; - protected override int ReducedSectionCount => 5; - protected override double DifficultyMultiplier => 1.04; - + private const double rhythm_multiplier = 0.75; + private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max. private const double min_speed_bonus = 75; // ~200BPM private const double speed_balancing_factor = 40; + private double skillMultiplier => 1375; + private double strainDecayBase => 0.3; + + private double currentStrain = 1; + private double currentRhythm = 1; + + protected override int ReducedSectionCount => 5; + protected override double DifficultyMultiplier => 1.04; + protected override int HistoryLength => 32; + private readonly double greatWindow; public Speed(Mod[] mods, double hitWindowGreat) @@ -37,52 +39,138 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills greatWindow = hitWindowGreat; } - protected override double StrainValueOf(DifficultyHitObject current) + /// + /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . + /// + private double calculateRhythmBonus(DifficultyHitObject current) { if (current.BaseObject is Spinner) return 0; - var osuCurrent = (OsuDifficultyHitObject)current; - var osuPrevious = Previous.Count > 0 ? (OsuDifficultyHitObject)Previous[0] : null; + int previousIslandSize = 0; - double distance = Math.Min(single_spacing_threshold, osuCurrent.TravelDistance + osuCurrent.JumpDistance); - double strainTime = osuCurrent.StrainTime; + double rhythmComplexitySum = 0; + int islandSize = 1; + double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms + bool firstDeltaSwitch = false; + + for (int i = Previous.Count - 2; i > 0; i--) + { + OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)Previous[i - 1]; + OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)Previous[i]; + OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)Previous[i + 1]; + + double currHistoricalDecay = Math.Max(0, (history_time_max - (current.StartTime - currObj.StartTime))) / history_time_max; // scales note 0 to 1 from history to now + + if (currHistoricalDecay != 0) + { + currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. + + double currDelta = currObj.StrainTime; + double prevDelta = prevObj.StrainTime; + double lastDelta = lastObj.StrainTime; + double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. + + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - greatWindow * 0.6) / (greatWindow * 0.6)); + + windowPenalty = Math.Min(1, windowPenalty); + + double effectiveRatio = windowPenalty * currRatio; + + if (firstDeltaSwitch) + { + if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) + { + if (islandSize < 7) + islandSize++; // island is still progressing, count size. + } + else + { + if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window + effectiveRatio *= 0.125; + + if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle + effectiveRatio *= 0.25; + + if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) + effectiveRatio *= 0.25; + + if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) + effectiveRatio *= 0.50; + + if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + effectiveRatio *= 0.125; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + + startRatio = effectiveRatio; + + previousIslandSize = islandSize; // log the last island size. + + if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + + islandSize = 1; + } + } + else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + { + // Begin counting island until we change speed again. + firstDeltaSwitch = true; + startRatio = effectiveRatio; + islandSize = 1; + } + } + } + + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) + } + + private double strainValueOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + // derive strainTime for calculation + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuPrevObj = Previous.Count > 0 ? (OsuDifficultyHitObject)Previous[0] : null; + + double strainTime = osuCurrObj.StrainTime; double greatWindowFull = greatWindow * 2; double speedWindowRatio = strainTime / greatWindowFull; // Aim to nerf cheesy rhythms (Very fast consecutive doubles with large deltatimes between) - if (osuPrevious != null && strainTime < greatWindowFull && osuPrevious.StrainTime > strainTime) - strainTime = Interpolation.Lerp(osuPrevious.StrainTime, strainTime, speedWindowRatio); + if (osuPrevObj != null && strainTime < greatWindowFull && osuPrevObj.StrainTime > strainTime) + strainTime = Interpolation.Lerp(osuPrevObj.StrainTime, strainTime, speedWindowRatio); // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. strainTime /= Math.Clamp((strainTime / greatWindowFull) / 0.93, 0.92, 1); + // derive speedBonus for calculation double speedBonus = 1.0; + if (strainTime < min_speed_bonus) - speedBonus = 1 + Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); + speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); - double angleBonus = 1.0; + double distance = Math.Min(single_spacing_threshold, osuCurrObj.TravelDistance + osuCurrObj.JumpDistance); - if (osuCurrent.Angle != null && osuCurrent.Angle.Value < angle_bonus_begin) - { - angleBonus = 1 + Math.Pow(Math.Sin(1.5 * (angle_bonus_begin - osuCurrent.Angle.Value)), 2) / 3.57; + return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / strainTime; + } - if (osuCurrent.Angle.Value < pi_over_2) - { - angleBonus = 1.28; - if (distance < 90 && osuCurrent.Angle.Value < pi_over_4) - angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1); - else if (distance < 90) - angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1) * Math.Sin((pi_over_2 - osuCurrent.Angle.Value) / pi_over_4); - } - } + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - return (1 + (speedBonus - 1) * 0.75) - * angleBonus - * (0.95 + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) - / strainTime; + protected override double CalculateInitialStrain(double time) => (currentStrain * currentRhythm) * strainDecay(time - Previous[0].StartTime); + + protected override double StrainValueAt(DifficultyHitObject current) + { + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += strainValueOf(current) * skillMultiplier; + + currentRhythm = calculateRhythmBonus(current); + + return currentStrain * currentRhythm; } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index dd3c6b317a..4b0b74ad27 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(2.2867022617692685d, "diffcalc-test")] - [TestCase(2.2867022617692685d, "diffcalc-test-strong")] + [TestCase(2.2420075288523802d, "diffcalc-test")] + [TestCase(2.2420075288523802d, "diffcalc-test-strong")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(3.1704781712282624d, "diffcalc-test")] - [TestCase(3.1704781712282624d, "diffcalc-test-strong")] + [TestCase(3.134084469440479d, "diffcalc-test")] + [TestCase(3.134084469440479d, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new TaikoModDoubleTime()); diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index 4708af99e2..32aad6c36a 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -47,10 +47,10 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { - if (!(original.Difficulty is TaikoMutliplierAppliedDifficulty)) + if (!(original.Difficulty is TaikoMultiplierAppliedDifficulty)) { // Rewrite the beatmap info to add the slider velocity multiplier - original.Difficulty = new TaikoMutliplierAppliedDifficulty(original.Difficulty); + original.Difficulty = new TaikoMultiplierAppliedDifficulty(original.Difficulty); } Beatmap converted = base.ConvertBeatmap(original, cancellationToken); @@ -191,15 +191,15 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps protected override Beatmap CreateBeatmap() => new TaikoBeatmap(); - private class TaikoMutliplierAppliedDifficulty : BeatmapDifficulty + private class TaikoMultiplierAppliedDifficulty : BeatmapDifficulty { - public TaikoMutliplierAppliedDifficulty(IBeatmapDifficultyInfo difficulty) + public TaikoMultiplierAppliedDifficulty(IBeatmapDifficultyInfo difficulty) { CopyFrom(difficulty); } [UsedImplicitly] - public TaikoMutliplierAppliedDifficulty() + public TaikoMultiplierAppliedDifficulty() { } @@ -208,14 +208,14 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps public override void CopyTo(BeatmapDifficulty other) { base.CopyTo(other); - if (!(other is TaikoMutliplierAppliedDifficulty)) + if (!(other is TaikoMultiplierAppliedDifficulty)) SliderMultiplier /= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; } public override void CopyFrom(IBeatmapDifficultyInfo other) { base.CopyFrom(other); - if (!(other is TaikoMutliplierAppliedDifficulty)) + if (!(other is TaikoMultiplierAppliedDifficulty)) SliderMultiplier *= LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index e755bb2325..7dd47e804b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -94,8 +94,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StaminaStrain = staminaRating, RhythmStrain = rhythmRating, ColourStrain = colourRating, - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)hitWindows.WindowFor(HitResult.Great) / clockRate, + GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, MaxCombo = beatmap.HitObjects.Count(h => h is Hit), Skills = skills }; diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 2c2c4dc24e..af87fc17ad 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -509,5 +509,17 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(LinkAction.External, result.Action); Assert.AreEqual("/relative", result.Argument); } + + [TestCase("https://dev.ppy.sh/home/changelog", "")] + [TestCase("https://dev.ppy.sh/home/changelog/lazer/2021.1012", "lazer/2021.1012")] + public void TestChangelogLinks(string link, string expectedArg) + { + MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; + + LinkDetails result = MessageFormatter.GetLinkDetails(link); + + Assert.AreEqual(LinkAction.OpenChangelog, result.Action); + Assert.AreEqual(expectedArg, result.Argument); + } } } diff --git a/osu.Game.Tests/Database/FileStoreTests.cs b/osu.Game.Tests/Database/FileStoreTests.cs new file mode 100644 index 0000000000..861de5303d --- /dev/null +++ b/osu.Game.Tests/Database/FileStoreTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Logging; +using osu.Game.Models; +using osu.Game.Stores; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + public class FileStoreTests : RealmTest + { + [Test] + public void TestImportFile() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + realm.Write(() => files.Add(testData, realm)); + + Assert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8")); + Assert.True(files.Storage.Exists(realm.All().First().StoragePath)); + }); + } + + [Test] + public void TestImportSameFileTwice() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + realm.Write(() => files.Add(testData, realm)); + realm.Write(() => files.Add(testData, realm)); + + Assert.AreEqual(1, realm.All().Count()); + }); + } + + [Test] + public void TestDontPurgeReferenced() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm)); + + var timer = new Stopwatch(); + timer.Start(); + + realm.Write(() => + { + // attach the file to an arbitrary beatmap + var beatmapSet = CreateBeatmapSet(CreateRuleset()); + + beatmapSet.Files.Add(new RealmNamedFileUsage(file, "arbitrary.resource")); + + realm.Add(beatmapSet); + }); + + Logger.Log($"Import complete at {timer.ElapsedMilliseconds}"); + + string path = file.StoragePath; + + Assert.True(realm.All().Any()); + Assert.True(files.Storage.Exists(path)); + + files.Cleanup(); + Logger.Log($"Cleanup complete at {timer.ElapsedMilliseconds}"); + + Assert.True(realm.All().Any()); + Assert.True(file.IsValid); + Assert.True(files.Storage.Exists(path)); + }); + } + + [Test] + public void TestPurgeUnreferenced() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm)); + + string path = file.StoragePath; + + Assert.True(realm.All().Any()); + Assert.True(files.Storage.Exists(path)); + + files.Cleanup(); + + Assert.False(realm.All().Any()); + Assert.False(file.IsValid); + Assert.False(files.Storage.Exists(path)); + }); + } + } +} diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs index 245981cd9b..3e8b6091fd 100644 --- a/osu.Game.Tests/Database/GeneralUsageTests.cs +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + using System; using System.Threading; using System.Threading.Tasks; diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs new file mode 100644 index 0000000000..33aa1afb89 --- /dev/null +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Models; +using Realms; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + public class RealmLiveTests : RealmTest + { + [Test] + public void TestLiveCastability() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(); + + ILive iBeatmap = beatmap; + + Assert.AreEqual(0, iBeatmap.Value.Length); + }); + } + + [Test] + public void TestValueAccessWithOpenContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + Assert.DoesNotThrow(() => + { + using (realmFactory.CreateContext()) + { + var resolved = liveBeatmap.Value; + + Assert.IsTrue(resolved.Realm.IsClosed); + Assert.IsTrue(resolved.IsValid); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + } + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestScopedReadWithoutContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + liveBeatmap.PerformRead(beatmap => + { + Assert.IsTrue(beatmap.IsValid); + Assert.IsFalse(beatmap.Hidden); + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestScopedWriteWithoutContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; }); + liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestValueAccessWithoutOpenContextFails() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + Assert.Throws(() => + { + var unused = liveBeatmap.Value; + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestLiveAssumptions() + { + RunTestWithRealm((realmFactory, _) => + { + int changesTriggered = 0; + + using (var updateThreadContext = realmFactory.CreateContext()) + { + updateThreadContext.All().SubscribeForNotifications(gotChange); + RealmLive? liveBeatmap = null; + + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var ruleset = CreateRuleset(); + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + // add a second beatmap to ensure that a full refresh occurs below. + // not just a refresh from the resolved Live. + threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + // not yet seen by main context + Assert.AreEqual(0, updateThreadContext.All().Count()); + Assert.AreEqual(0, changesTriggered); + + var resolved = liveBeatmap.Value; + + // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. + Assert.AreEqual(2, updateThreadContext.All().Count()); + Assert.AreEqual(1, changesTriggered); + + // even though the realm that this instance was resolved for was closed, it's still valid. + Assert.IsTrue(resolved.Realm.IsClosed); + Assert.IsTrue(resolved.IsValid); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + + updateThreadContext.Write(r => + { + // can use with the main context. + r.Remove(resolved); + }); + } + + void gotChange(IRealmCollection sender, ChangeSet changes, Exception error) + { + changesTriggered++; + } + }); + } + } +} diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index 576f901c1a..04c9f2577a 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -4,12 +4,13 @@ using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Nito.AsyncEx; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Models; #nullable enable @@ -28,42 +29,109 @@ namespace osu.Game.Tests.Database protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") { - AsyncContext.Run(() => + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) { - var testStorage = storage.GetStorageForDirectory(caller); - - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + host.Run(new RealmTestGame(() => { - Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); - testAction(realmFactory, testStorage); + var testStorage = storage.GetStorageForDirectory(caller); - realmFactory.Dispose(); + using (var realmFactory = new RealmContextFactory(testStorage, caller)) + { + Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); + testAction(realmFactory, testStorage); - Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); - realmFactory.Compact(); - Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); - } - }); + realmFactory.Dispose(); + + Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Compact(); + Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); + } + })); + } } protected void RunTestWithRealmAsync(Func testAction, [CallerMemberName] string caller = "") { - AsyncContext.Run(async () => + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) { - var testStorage = storage.GetStorageForDirectory(caller); - - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + host.Run(new RealmTestGame(async () => { - Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); - await testAction(realmFactory, testStorage); + var testStorage = storage.GetStorageForDirectory(caller); - realmFactory.Dispose(); + using (var realmFactory = new RealmContextFactory(testStorage, caller)) + { + Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); + await testAction(realmFactory, testStorage); - Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); - realmFactory.Compact(); - Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Dispose(); + + Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Compact(); + } + })); + } + } + + protected static RealmBeatmapSet CreateBeatmapSet(RealmRuleset ruleset) + { + RealmFile createRealmFile() => new RealmFile { Hash = Guid.NewGuid().ToString().ComputeSHA2Hash() }; + + var metadata = new RealmBeatmapMetadata + { + Title = "My Love", + Artist = "Kuba Oms" + }; + + var beatmapSet = new RealmBeatmapSet + { + Beatmaps = + { + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Easy", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Normal", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Hard", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Insane", } + }, + Files = + { + new RealmNamedFileUsage(createRealmFile(), "test [easy].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [normal].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [hard].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [insane].osu"), } - }); + }; + + for (int i = 0; i < 8; i++) + beatmapSet.Files.Add(new RealmNamedFileUsage(createRealmFile(), $"hitsound{i}.mp3")); + + foreach (var b in beatmapSet.Beatmaps) + b.BeatmapSet = beatmapSet; + + return beatmapSet; + } + + protected static RealmRuleset CreateRuleset() => + new RealmRuleset(0, "osu!", "osu", true); + + private class RealmTestGame : Framework.Game + { + public RealmTestGame(Func work) + { + // ReSharper disable once AsyncVoidLambda + Scheduler.Add(async () => + { + await work().ConfigureAwait(true); + Exit(); + }); + } + + public RealmTestGame(Action work) + { + Scheduler.Add(() => + { + work(); + Exit(); + }); + } } private static long getFileSize(Storage testStorage, RealmContextFactory realmFactory) diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 79767bc671..558b874234 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -168,14 +168,14 @@ namespace osu.Game.Tests.Online return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host); } - protected override BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host) + protected override BeatmapModelDownloader CreateBeatmapModelDownloader(IBeatmapModelManager manager, IAPIProvider api, GameHost host) { - return new TestBeatmapModelDownloader(modelManager, api, host); + return new TestBeatmapModelDownloader(manager, api, host); } internal class TestBeatmapModelDownloader : BeatmapModelDownloader { - public TestBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost) + public TestBeatmapModelDownloader(IBeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost) : base(modelManager, apiProvider, gameHost) { } diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs index ab47067411..ffb3d41d18 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs @@ -6,7 +6,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; @@ -65,10 +64,9 @@ namespace osu.Game.Tests.Skins public new void TriggerSourceChanged() => base.TriggerSourceChanged(); - protected override void OnSourceChanged() + protected override void RefreshSources() { - ResetSources(); - sources.ForEach(AddSource); + SetSources(sources); } } diff --git a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs index 211543a881..0107632f6e 100644 --- a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs +++ b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -18,16 +19,19 @@ namespace osu.Game.Tests.Visual.Audio { public class TestSceneAudioFilter : OsuTestScene { - private OsuSpriteText lowpassText; - private AudioFilter lowpassFilter; + private OsuSpriteText lowPassText; + private AudioFilter lowPassFilter; - private OsuSpriteText highpassText; - private AudioFilter highpassFilter; + private OsuSpriteText highPassText; + private AudioFilter highPassFilter; private Track track; private WaveformTestBeatmap beatmap; + private OsuSliderBar lowPassSlider; + private OsuSliderBar highPassSlider; + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -38,53 +42,89 @@ namespace osu.Game.Tests.Visual.Audio { Children = new Drawable[] { - lowpassFilter = new AudioFilter(audio.TrackMixer), - highpassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), - lowpassText = new OsuSpriteText + lowPassFilter = new AudioFilter(audio.TrackMixer), + highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), + lowPassText = new OsuSpriteText { Padding = new MarginPadding(20), - Text = $"Low Pass: {lowpassFilter.Cutoff.Value}hz", + Text = $"Low Pass: {lowPassFilter.Cutoff}hz", Font = new FontUsage(size: 40) }, - new OsuSliderBar + lowPassSlider = new OsuSliderBar { Width = 500, Height = 50, Padding = new MarginPadding(20), - Current = { BindTarget = lowpassFilter.Cutoff } + Current = new BindableInt + { + MinValue = 0, + MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF, + } }, - highpassText = new OsuSpriteText + highPassText = new OsuSpriteText { Padding = new MarginPadding(20), - Text = $"High Pass: {highpassFilter.Cutoff.Value}hz", + Text = $"High Pass: {highPassFilter.Cutoff}hz", Font = new FontUsage(size: 40) }, - new OsuSliderBar + highPassSlider = new OsuSliderBar { Width = 500, Height = 50, Padding = new MarginPadding(20), - Current = { BindTarget = highpassFilter.Cutoff } + Current = new BindableInt + { + MinValue = 0, + MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF, + } } } }); - lowpassFilter.Cutoff.ValueChanged += e => lowpassText.Text = $"Low Pass: {e.NewValue}hz"; - highpassFilter.Cutoff.ValueChanged += e => highpassText.Text = $"High Pass: {e.NewValue}hz"; + + lowPassSlider.Current.ValueChanged += e => + { + lowPassText.Text = $"Low Pass: {e.NewValue}hz"; + lowPassFilter.Cutoff = e.NewValue; + }; + + highPassSlider.Current.ValueChanged += e => + { + highPassText.Text = $"High Pass: {e.NewValue}hz"; + highPassFilter.Cutoff = e.NewValue; + }; } + #region Overrides of Drawable + + protected override void Update() + { + base.Update(); + highPassSlider.Current.Value = highPassFilter.Cutoff; + lowPassSlider.Current.Value = lowPassFilter.Cutoff; + } + + #endregion + [SetUpSteps] public void SetUpSteps() { AddStep("Play Track", () => track.Start()); + + AddStep("Reset filters", () => + { + lowPassFilter.Cutoff = AudioFilter.MAX_LOWPASS_CUTOFF; + highPassFilter.Cutoff = 0; + }); + waitTrackPlay(); } [Test] - public void TestLowPass() + public void TestLowPassSweep() { AddStep("Filter Sweep", () => { - lowpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() .CutoffTo(0, 2000, Easing.OutCubic); }); @@ -92,7 +132,7 @@ namespace osu.Game.Tests.Visual.Audio AddStep("Filter Sweep (reverse)", () => { - lowpassFilter.CutoffTo(0).Then() + lowPassFilter.CutoffTo(0).Then() .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); }); @@ -101,11 +141,11 @@ namespace osu.Game.Tests.Visual.Audio } [Test] - public void TestHighPass() + public void TestHighPassSweep() { AddStep("Filter Sweep", () => { - highpassFilter.CutoffTo(0).Then() + highPassFilter.CutoffTo(0).Then() .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); }); @@ -113,7 +153,7 @@ namespace osu.Game.Tests.Visual.Audio AddStep("Filter Sweep (reverse)", () => { - highpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() + highPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() .CutoffTo(0, 2000, Easing.OutCubic); }); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 2258a209e2..f0aa3e2350 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -32,6 +32,8 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for editor load", () => editor != null); + AddStep("Set overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty = 7); + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); AddStep("Enter compose mode", () => InputManager.Key(Key.F1)); @@ -41,11 +43,11 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Move to playfield", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); AddStep("Place single hitcircle", () => InputManager.Click(MouseButton.Left)); - AddStep("Save and exit", () => - { - InputManager.Keys(PlatformAction.Save); - InputManager.Key(Key.Escape); - }); + AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1); + + AddStep("Save", () => InputManager.Keys(PlatformAction.Save)); + + AddStep("Exit", () => InputManager.Key(Key.Escape)); AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -57,6 +59,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Wait for editor load", () => editor != null); AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1); + AddAssert("Beatmap has correct overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty == 7); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index 5eb71e92c2..ae0decaee1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -103,6 +103,30 @@ namespace osu.Game.Tests.Visual.Gameplay checkFrameCount(0); } + [Test] + public void TestRatePreservedWhenTimeNotProgressing() + { + AddStep("set manual clock rate", () => manualClock.Rate = 1); + seekManualTo(5000); + createStabilityContainer(); + checkRate(1); + + seekManualTo(10000); + checkRate(1); + + AddWaitStep("wait some", 3); + checkRate(1); + + seekManualTo(5000); + checkRate(-1); + + AddWaitStep("wait some", 3); + checkRate(-1); + + seekManualTo(10000); + checkRate(1); + } + private const int max_frames_catchup = 50; private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => @@ -116,6 +140,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkFrameCount(int frames) => AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames); + private void checkRate(double rate) => + AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate); + public class ClockConsumingChild : CompositeDrawable { private readonly OsuSpriteText text; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 3ed274690e..48a97d54f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -90,8 +90,12 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(() => { AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); - AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300); + + // Fail occurs at 164ms with the provided beatmap. + // Fail animation runs for 2.5s realtime but the gameplay time change is *variable* due to the frequency transform being applied, so we need a bit of lenience. + AddStep("set storyboard duration to 0.6s", () => currentStoryboardDuration = 600); }); + AddUntilStep("wait for fail", () => Player.HasFailed); AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs new file mode 100644 index 0000000000..cb7c334656 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSceneStartupImport : OsuGameTestScene + { + private string importFilename; + + protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename }); + + public override void SetUpSteps() + { + AddStep("Prepare import beatmap", () => importFilename = TestResources.GetTestBeatmapForImport()); + + base.SetUpSteps(); + } + + [Test] + public void TestImportCreatedNotification() + { + AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType().Count() == 1); + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 4bcc887b9f..d948aebbbf 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -32,12 +32,14 @@ namespace osu.Game.Tests.Visual.Playlists private TestResultsScreen resultsScreen; private int currentScoreId; private bool requestComplete; + private int totalCount; [SetUp] public void Setup() => Schedule(() => { currentScoreId = 0; requestComplete = false; + totalCount = 0; bindHandler(); }); @@ -53,7 +55,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); } @@ -62,7 +63,6 @@ namespace osu.Game.Tests.Visual.Playlists public void TestShowNullUserScore() { createResults(); - waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } @@ -79,7 +79,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); @@ -91,7 +90,6 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind delayed handler", () => bindHandler(true)); createResults(); - waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } @@ -100,7 +98,6 @@ namespace osu.Game.Tests.Visual.Playlists public void TestFetchWhenScrolledToTheRight() { createResults(); - waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -131,7 +128,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -161,13 +157,15 @@ namespace osu.Game.Tests.Visual.Playlists })); }); - AddUntilStep("wait for load", () => resultsScreen.ChildrenOfType().FirstOrDefault()?.AllPanelsVisible == true); + waitForDisplay(); } private void waitForDisplay() { - AddUntilStep("wait for request to complete", () => requestComplete); - AddUntilStep("wait for panels to be visible", () => resultsScreen.ChildrenOfType().FirstOrDefault()?.AllPanelsVisible == true); + AddUntilStep("wait for load to complete", () => + requestComplete + && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount + && resultsScreen.ScorePanelList.AllPanelsVisible); AddWaitStep("wait for display", 5); } @@ -203,6 +201,7 @@ namespace osu.Game.Tests.Visual.Playlists triggerFail(s); else triggerSuccess(s, createUserResponse(userScore)); + break; case IndexPlaylistScoresRequest i: @@ -248,6 +247,8 @@ namespace osu.Game.Tests.Visual.Playlists } }; + totalCount++; + for (int i = 1; i <= scores_per_result; i++) { multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore @@ -285,6 +286,8 @@ namespace osu.Game.Tests.Visual.Playlists }, Statistics = userScore.Statistics }); + + totalCount += 2; } addCursor(multiplayerUserScore.ScoresAround.Lower); @@ -325,6 +328,8 @@ namespace osu.Game.Tests.Visual.Playlists { HitResult.Great, 300 } } }); + + totalCount++; } addCursor(result); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs index e3dae9c27e..d530e1f796 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs @@ -53,7 +53,11 @@ namespace osu.Game.Tests.Visual.Settings }; [SettingSource("Sample string", "Change something for a mod")] - public Bindable StringBindable { get; } = new Bindable(); + public Bindable StringBindable { get; } = new Bindable + { + Default = string.Empty, + Value = "Sample text" + }; } private enum TestEnum diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs index 997eac709d..dc5b0e0d77 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Tablet; @@ -21,6 +22,9 @@ namespace osu.Game.Tests.Visual.Settings private TestTabletHandler tabletHandler; private TabletSettings settings; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + [SetUpSteps] public void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 067f1cabb4..4811fc979e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -142,6 +142,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("store selected beatmap", () => selected = Beatmap.Value); + AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any()); + AddStep("select next and enter", () => { InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() @@ -599,10 +601,10 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableDifficultyIcon difficultyIcon = null; - AddStep("Find an icon", () => + AddUntilStep("Find an icon", () => { - difficultyIcon = set.ChildrenOfType() - .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + return (difficultyIcon = set.ChildrenOfType() + .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null; }); AddStep("Click on a difficulty", () => @@ -765,10 +767,10 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableGroupedDifficultyIcon groupIcon = null; - AddStep("Find group icon for different ruleset", () => + AddUntilStep("Find group icon for different ruleset", () => { - groupIcon = set.ChildrenOfType() - .First(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3); + return (groupIcon = set.ChildrenOfType() + .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3)) != null; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 189b143a35..9a75d3c309 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -163,7 +163,6 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("wait for fetch", () => leaderboard.Scores != null); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scoreBeingDeleted.OnlineScoreID)); } @@ -171,6 +170,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestDeleteViaDatabase() { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); + AddUntilStep("wait for fetch", () => leaderboard.Scores != null); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != importedScores[0].OnlineScoreID)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 546e905ded..8d1572769f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -36,9 +36,9 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep(@"Add DoubleTime", () => changeMods(doubleTimeMod)); AddAssert(@"Check DoubleTime multiplier", () => assertModsMultiplier(doubleTimeMod)); - var mutlipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; - AddStep(@"Add multiple Mods", () => changeMods(mutlipleIncrementMods)); - AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(mutlipleIncrementMods)); + var multipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; + AddStep(@"Add multiple Mods", () => changeMods(multipleIncrementMods)); + AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleIncrementMods)); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs new file mode 100644 index 0000000000..9ccfba7c74 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneRoundedButton : OsuTestScene + { + [Test] + public void TestBasic() + { + RoundedButton button = null; + + AddStep("create button", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.DarkGray + }, + button = new RoundedButton + { + Width = 400, + Text = "Test button", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => { } + } + } + }); + + AddToggleStep("toggle disabled", disabled => button.Action = disabled ? (Action)null : () => { }); + } + } +} diff --git a/osu.Game.Tournament/Components/DateTextBox.cs b/osu.Game.Tournament/Components/DateTextBox.cs index 5782301a65..2237e389d7 100644 --- a/osu.Game.Tournament/Components/DateTextBox.cs +++ b/osu.Game.Tournament/Components/DateTextBox.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tournament.Components public DateTextBox() { - base.Current = new Bindable(); + base.Current = new Bindable(string.Empty); ((OsuTextBox)Control).OnCommit += (sender, newText) => { diff --git a/osu.Game.Tournament/Models/SeedingResult.cs b/osu.Game.Tournament/Models/SeedingResult.cs index 87aaf8bf36..d37c967762 100644 --- a/osu.Game.Tournament/Models/SeedingResult.cs +++ b/osu.Game.Tournament/Models/SeedingResult.cs @@ -10,7 +10,7 @@ namespace osu.Game.Tournament.Models { public List Beatmaps = new List(); - public Bindable Mod = new Bindable(); + public Bindable Mod = new Bindable(string.Empty); public Bindable Seed = new BindableInt { diff --git a/osu.Game.Tournament/Models/TournamentRound.cs b/osu.Game.Tournament/Models/TournamentRound.cs index 08b3143be1..ab39605d07 100644 --- a/osu.Game.Tournament/Models/TournamentRound.cs +++ b/osu.Game.Tournament/Models/TournamentRound.cs @@ -14,8 +14,8 @@ namespace osu.Game.Tournament.Models [Serializable] public class TournamentRound { - public readonly Bindable Name = new Bindable(); - public readonly Bindable Description = new Bindable(); + public readonly Bindable Name = new Bindable(string.Empty); + public readonly Bindable Description = new Bindable(string.Empty); public readonly BindableInt BestOf = new BindableInt(9) { Default = 9, MinValue = 3, MaxValue = 23 }; diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 6e4fc8fe1a..1d8c4e7476 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -149,7 +149,7 @@ namespace osu.Game.Tournament.Screens.Editors private readonly Bindable beatmapId = new Bindable(); - private readonly Bindable mods = new Bindable(); + private readonly Bindable mods = new Bindable(string.Empty); private readonly Container drawableContainer; diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index b64a3993e6..d5b55823a5 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -149,7 +149,7 @@ namespace osu.Game.Tournament.Screens.Editors private readonly Bindable beatmapId = new Bindable(); - private readonly Bindable score = new Bindable(); + private readonly Bindable score = new Bindable(string.Empty); private readonly Container drawableContainer; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs index 994dee4da0..77101e4023 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs @@ -127,7 +127,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - private class MatchScoreCounter : ScoreCounter + private class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs index ee48bdd7d9..d2a39e9db7 100644 --- a/osu.Game/Audio/Effects/AudioFilter.cs +++ b/osu.Game/Audio/Effects/AudioFilter.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using ManagedBass.Fx; using osu.Framework.Audio.Mixing; -using osu.Framework.Bindables; using osu.Framework.Graphics; namespace osu.Game.Audio.Effects @@ -21,10 +20,25 @@ namespace osu.Game.Audio.Effects private readonly BQFParameters filter; private readonly BQFType type; + private bool isAttached; + + private int cutoff; + /// - /// The current cutoff of this filter. + /// The cutoff frequency of this filter. /// - public BindableNumber Cutoff { get; } + public int Cutoff + { + get => cutoff; + set + { + if (value == cutoff) + return; + + cutoff = value; + updateFilter(cutoff); + } + } /// /// A Component that implements a BASS FX BiQuad Filter Effect. @@ -36,102 +50,96 @@ namespace osu.Game.Audio.Effects this.mixer = mixer; this.type = type; - int initialCutoff; - - switch (type) - { - case BQFType.HighPass: - initialCutoff = 1; - break; - - case BQFType.LowPass: - initialCutoff = MAX_LOWPASS_CUTOFF; - break; - - default: - initialCutoff = 500; // A default that should ensure audio remains audible for other filters. - break; - } - - Cutoff = new BindableNumber(initialCutoff) - { - MinValue = 1, - MaxValue = MAX_LOWPASS_CUTOFF - }; - filter = new BQFParameters { lFilter = type, - fCenter = initialCutoff, fBandwidth = 0, - fQ = 0.7f // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0) + // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0) + fQ = 0.7f }; - // Don't start attached if this is low-pass or high-pass filter (as they have special auto-attach/detach logic) - if (type != BQFType.LowPass && type != BQFType.HighPass) - attachFilter(); - - Cutoff.ValueChanged += updateFilter; + Cutoff = getInitialCutoff(type); } - private void attachFilter() + private int getInitialCutoff(BQFType type) { - Debug.Assert(!mixer.Effects.Contains(filter)); - mixer.Effects.Add(filter); - } - - private void detachFilter() - { - Debug.Assert(mixer.Effects.Contains(filter)); - mixer.Effects.Remove(filter); - } - - private void updateFilter(ValueChangedEvent cutoff) - { - // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. - if (type == BQFType.LowPass) + switch (type) { - if (cutoff.NewValue >= MAX_LOWPASS_CUTOFF) - { - detachFilter(); - return; - } + case BQFType.HighPass: + return 1; - if (cutoff.OldValue >= MAX_LOWPASS_CUTOFF && cutoff.NewValue < MAX_LOWPASS_CUTOFF) - attachFilter(); + case BQFType.LowPass: + return MAX_LOWPASS_CUTOFF; + + default: + return 500; // A default that should ensure audio remains audible for other filters. + } + } + + private void updateFilter(int newValue) + { + switch (type) + { + case BQFType.LowPass: + // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. + if (newValue >= MAX_LOWPASS_CUTOFF) + { + ensureDetached(); + return; + } + + break; + + // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. + case BQFType.HighPass: + if (newValue <= 1) + { + ensureDetached(); + return; + } + + break; } - // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. - if (type == BQFType.HighPass) - { - if (cutoff.NewValue <= 1) - { - detachFilter(); - return; - } - - if (cutoff.OldValue <= 1 && cutoff.NewValue > 1) - attachFilter(); - } + ensureAttached(); var filterIndex = mixer.Effects.IndexOf(filter); + if (filterIndex < 0) return; if (mixer.Effects[filterIndex] is BQFParameters existingFilter) { - existingFilter.fCenter = cutoff.NewValue; + existingFilter.fCenter = newValue; // required to update effect with new parameters. mixer.Effects[filterIndex] = existingFilter; } } + private void ensureAttached() + { + if (isAttached) + return; + + Debug.Assert(!mixer.Effects.Contains(filter)); + mixer.Effects.Add(filter); + isAttached = true; + } + + private void ensureDetached() + { + if (!isAttached) + return; + + Debug.Assert(mixer.Effects.Contains(filter)); + mixer.Effects.Remove(filter); + isAttached = false; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - - if (mixer.Effects.Contains(filter)) - detachFilter(); + ensureDetached(); } } } diff --git a/osu.Game/Audio/Effects/ITransformableFilter.cs b/osu.Game/Audio/Effects/ITransformableFilter.cs index e4de4cf8ff..fb6a924f68 100644 --- a/osu.Game/Audio/Effects/ITransformableFilter.cs +++ b/osu.Game/Audio/Effects/ITransformableFilter.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Transforms; @@ -12,7 +11,7 @@ namespace osu.Game.Audio.Effects /// /// The filter cutoff. /// - BindableNumber Cutoff { get; } + int Cutoff { get; set; } } public static class FilterableAudioComponentExtensions @@ -40,7 +39,7 @@ namespace osu.Game.Audio.Effects public static TransformSequence CutoffTo(this T component, int newCutoff, double duration, TEasing easing) where T : class, ITransformableFilter, IDrawable where TEasing : IEasingFunction - => component.TransformBindableTo(component.Cutoff, newCutoff, duration, easing); + => component.TransformTo(nameof(component.Cutoff), newCutoff, duration, easing); /// /// Smoothly adjusts filter cutoff over time. @@ -49,6 +48,6 @@ namespace osu.Game.Audio.Effects public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration, TEasing easing) where T : class, ITransformableFilter, IDrawable where TEasing : IEasingFunction - => sequence.Append(o => o.TransformBindableTo(o.Cutoff, newCutoff, duration, easing)); + => sequence.Append(o => o.TransformTo(nameof(o.Cutoff), newCutoff, duration, easing)); } } diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index f3434c5153..627e54c803 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -40,7 +40,13 @@ namespace osu.Game.Beatmaps public IBeatmap Convert(CancellationToken cancellationToken = default) { // We always operate on a clone of the original beatmap, to not modify it game-wide - return ConvertBeatmap(Beatmap.Clone(), cancellationToken); + var original = Beatmap.Clone(); + + // Shallow clone isn't enough to ensure we don't mutate beatmap info unexpectedly. + // Can potentially be removed after `Beatmap.Difficulty` doesn't save back to `Beatmap.BeatmapInfo`. + original.BeatmapInfo = original.BeatmapInfo.Clone(); + + return ConvertBeatmap(original, cancellationToken); } /// diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 240db22c00..14175f251b 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -54,7 +54,7 @@ namespace osu.Game.Beatmaps } } - protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host) + protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(IBeatmapModelManager modelManager, IAPIProvider api, GameHost host) { return new BeatmapModelDownloader(modelManager, api, host); } diff --git a/osu.Game/Beatmaps/BeatmapModelDownloader.cs b/osu.Game/Beatmaps/BeatmapModelDownloader.cs index ae482eeafd..30dc95a966 100644 --- a/osu.Game/Beatmaps/BeatmapModelDownloader.cs +++ b/osu.Game/Beatmaps/BeatmapModelDownloader.cs @@ -13,7 +13,7 @@ namespace osu.Game.Beatmaps protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => new DownloadBeatmapSetRequest(set, minimiseDownloadSize); - public BeatmapModelDownloader(BeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null) + public BeatmapModelDownloader(IBeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null) : base(beatmapModelManager, api, host) { } diff --git a/osu.Game/Configuration/RandomSelectAlgorithm.cs b/osu.Game/Configuration/RandomSelectAlgorithm.cs index 8d0c87374f..b22f2ae485 100644 --- a/osu.Game/Configuration/RandomSelectAlgorithm.cs +++ b/osu.Game/Configuration/RandomSelectAlgorithm.cs @@ -10,7 +10,7 @@ namespace osu.Game.Configuration [Description("Never repeat")] RandomPermutation, - [Description("Random")] + [Description("True Random")] Random } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index ee1a7e2900..c235fc7728 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -116,7 +116,7 @@ namespace osu.Game.Database /// One or more archive locations on disk. public Task Import(params string[] paths) { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + var notification = new ImportProgressNotification(); PostNotification?.Invoke(notification); @@ -125,7 +125,7 @@ namespace osu.Game.Database public Task Import(params ImportTask[] tasks) { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + var notification = new ImportProgressNotification(); PostNotification?.Invoke(notification); diff --git a/osu.Game/Database/IHasRealmFiles.cs b/osu.Game/Database/IHasRealmFiles.cs new file mode 100644 index 0000000000..024d9f2a89 --- /dev/null +++ b/osu.Game/Database/IHasRealmFiles.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Models; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// A model that contains a list of files it is responsible for. + /// + public interface IHasRealmFiles + { + IList Files { get; } + + string Hash { get; set; } + } +} diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index e94af01772..8e658cb0f5 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -10,7 +10,7 @@ using osu.Game.Overlays.Notifications; namespace osu.Game.Database { /// - /// A class which handles importing of asociated models to the game store. + /// A class which handles importing of associated models to the game store. /// /// The model type. public interface IModelImporter : IPostNotifications diff --git a/osu.Game/Database/INamedFile.cs b/osu.Game/Database/INamedFile.cs new file mode 100644 index 0000000000..2bd45d4e42 --- /dev/null +++ b/osu.Game/Database/INamedFile.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Models; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Represents a join model which gives a filename and scope to a . + /// + public interface INamedFile + { + string Filename { get; set; } + + RealmFile File { get; set; } + } +} diff --git a/osu.Game/Database/ImportProgressNotification.cs b/osu.Game/Database/ImportProgressNotification.cs new file mode 100644 index 0000000000..aaee3e117f --- /dev/null +++ b/osu.Game/Database/ImportProgressNotification.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + public class ImportProgressNotification : ProgressNotification + { + public ImportProgressNotification() + { + State = ProgressNotificationState.Active; + } + } +} diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index 0ff902a8bc..c3810eb441 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -135,9 +135,8 @@ namespace osu.Game.Database if (IsDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); - // TODO: this can be added for safety once we figure how to bypass in test - // if (!ThreadSafety.IsUpdateThread) - // throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread."); + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread."); Logger.Log(@"Blocking realm operations.", LoggingTarget.Database); diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs new file mode 100644 index 0000000000..abb69644d6 --- /dev/null +++ b/osu.Game/Database/RealmLive.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using Realms; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Provides a method of working with realm objects over longer application lifetimes. + /// + /// The underlying object type. + public class RealmLive : ILive where T : RealmObject, IHasGuidPrimaryKey + { + public Guid ID { get; } + + private readonly SynchronizationContext? fetchedContext; + private readonly int fetchedThreadId; + + /// + /// The original live data used to create this instance. + /// + private readonly T data; + + /// + /// Construct a new instance of live realm data. + /// + /// The realm data. + public RealmLive(T data) + { + this.data = data; + + fetchedContext = SynchronizationContext.Current; + fetchedThreadId = Thread.CurrentThread.ManagedThreadId; + + ID = data.ID; + } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + public void PerformRead(Action perform) + { + if (originalDataValid) + { + perform(data); + return; + } + + using (var realm = Realm.GetInstance(data.Realm.Config)) + perform(realm.Find(ID)); + } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + public TReturn PerformRead(Func perform) + { + if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn))) + throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}."); + + if (originalDataValid) + return perform(data); + + using (var realm = Realm.GetInstance(data.Realm.Config)) + return perform(realm.Find(ID)); + } + + /// + /// Perform a write operation on this live object. + /// + /// The action to perform. + public void PerformWrite(Action perform) => + PerformRead(t => + { + var transaction = t.Realm.BeginWrite(); + perform(t); + transaction.Commit(); + }); + + public T Value + { + get + { + if (originalDataValid) + return data; + + T retrieved; + + using (var realm = Realm.GetInstance(data.Realm.Config)) + retrieved = realm.Find(ID); + + if (!retrieved.IsValid) + throw new InvalidOperationException("Attempted to access value without an open context"); + + return retrieved; + } + } + + private bool originalDataValid => isCorrectThread && data.IsValid; + + // this matches realm's internal thread validation (see https://github.com/realm/realm-dotnet/blob/903b4d0b304f887e37e2d905384fb572a6496e70/Realm/Realm/Native/SynchronizationContextScheduler.cs#L72) + private bool isCorrectThread + => (fetchedContext != null && SynchronizationContext.Current == fetchedContext) || fetchedThreadId == Thread.CurrentThread.ManagedThreadId; + } +} diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index c5aa1399a3..18a926fa8c 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using AutoMapper; using osu.Game.Input.Bindings; using Realms; @@ -47,5 +48,17 @@ namespace osu.Game.Database return mapper.Map(item); } + + public static List> ToLive(this IEnumerable realmList) + where T : RealmObject, IHasGuidPrimaryKey + { + return realmList.Select(l => new RealmLive(l)).ToList(); + } + + public static RealmLive ToLive(this T realmObject) + where T : RealmObject, IHasGuidPrimaryKey + { + return new RealmLive(realmObject); + } } } diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index d7cfc4094c..af2bb26871 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -225,6 +225,16 @@ namespace osu.Game.Graphics public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee"); public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff"); + /// + /// Equivalent to 's . + /// + public readonly Color4 Pink3 = Color4Extensions.FromHex(@"cc3378"); + + /// + /// Equivalent to 's . + /// + public readonly Color4 Blue3 = Color4Extensions.FromHex(@"3399cc"); + /// /// Equivalent to 's . /// diff --git a/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs new file mode 100644 index 0000000000..4e1c612f09 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + public abstract class CommaSeparatedScoreCounter : RollingCounter + { + protected override double RollingDuration => 1000; + protected override Easing RollingEasing => Easing.Out; + + protected override double GetProportionalDuration(double currentValue, double newValue) => + currentValue > newValue ? currentValue - newValue : newValue - currentValue; + + protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(@"N0"); + + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); + } +} diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 7ebf3819e4..25f19aa0a9 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; @@ -13,43 +14,30 @@ namespace osu.Game.Graphics.UserInterface protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - /// - /// Whether comma separators should be displayed. - /// - public bool UseCommaSeparator { get; } - public Bindable RequiredDisplayDigits { get; } = new Bindable(); + private string formatString; + /// /// Displays score. /// /// How many leading zeroes the counter will have. - /// Whether comma separators should be displayed. - protected ScoreCounter(int leading = 0, bool useCommaSeparator = false) + protected ScoreCounter(int leading = 0) { - UseCommaSeparator = useCommaSeparator; - RequiredDisplayDigits.Value = leading; - RequiredDisplayDigits.BindValueChanged(_ => UpdateDisplay()); + RequiredDisplayDigits.BindValueChanged(displayDigitsChanged, true); } - protected override double GetProportionalDuration(double currentValue, double newValue) + private void displayDigitsChanged(ValueChangedEvent _) { - return currentValue > newValue ? currentValue - newValue : newValue - currentValue; + formatString = new string('0', RequiredDisplayDigits.Value); + UpdateDisplay(); } - protected override LocalisableString FormatCount(double count) - { - string format = new string('0', RequiredDisplayDigits.Value); + protected override double GetProportionalDuration(double currentValue, double newValue) => + currentValue > newValue ? currentValue - newValue : newValue - currentValue; - if (UseCommaSeparator) - { - for (int i = format.Length - 3; i > 0; i -= 3) - format = format.Insert(i, @","); - } - - return ((long)count).ToString(format); - } + protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(formatString); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs new file mode 100644 index 0000000000..27e28f1e03 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class RoundedButton : OsuButton, IFilterable + { + public override float Height + { + get => base.Height; + set + { + base.Height = value; + + if (IsLoaded) + updateCornerRadius(); + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.Blue3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateCornerRadius(); + } + + private void updateCornerRadius() => Content.CornerRadius = DrawHeight / 2; + + public virtual IEnumerable FilterTerms => new[] { Text.ToString() }; + + public bool MatchingFilter + { + set => this.FadeTo(value ? 1 : 0); + } + + public bool FilteringActive { get; set; } + } +} diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index aa6eabd7d1..008781c2e5 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString VolumeHeader => new TranslatableString(getKey(@"volume_header"), @"Volume"); + /// + /// "Output device" + /// + public static LocalisableString OutputDevice => new TranslatableString(getKey(@"output_device"), @"Output device"); + /// /// "Master" /// diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 6d6381b429..fa92187650 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -14,11 +14,36 @@ namespace osu.Game.Localisation /// public static LocalisableString GameplaySectionHeader => new TranslatableString(getKey(@"gameplay_section_header"), @"Gameplay"); + /// + /// "Beatmap" + /// + public static LocalisableString BeatmapHeader => new TranslatableString(getKey(@"beatmap_header"), @"Beatmap"); + /// /// "General" /// public static LocalisableString GeneralHeader => new TranslatableString(getKey(@"general_header"), @"General"); + /// + /// "Audio" + /// + public static LocalisableString AudioHeader => new TranslatableString(getKey(@"audio"), @"Audio"); + + /// + /// "HUD" + /// + public static LocalisableString HUDHeader => new TranslatableString(getKey(@"h_u_d"), @"HUD"); + + /// + /// "Input" + /// + public static LocalisableString InputHeader => new TranslatableString(getKey(@"input"), @"Input"); + + /// + /// "Background" + /// + public static LocalisableString BackgroundHeader => new TranslatableString(getKey(@"background"), @"Background"); + /// /// "Background dim" /// diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 0e384f983f..f85cc0f2ae 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -104,6 +104,11 @@ namespace osu.Game.Localisation /// public static LocalisableString HitLighting => new TranslatableString(getKey(@"hit_lighting"), @"Hit lighting"); + /// + /// "Screenshots" + /// + public static LocalisableString Screenshots => new TranslatableString(getKey(@"screenshots"), @"Screenshots"); + /// /// "Screenshot format" /// diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs new file mode 100644 index 0000000000..a356c9e20b --- /dev/null +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class RulesetSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.RulesetSettings"; + + /// + /// "Rulesets" + /// + public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index f22b4d6bf5..8b74b94d59 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -14,6 +14,11 @@ namespace osu.Game.Localisation /// public static LocalisableString SkinSectionHeader => new TranslatableString(getKey(@"skin_section_header"), @"Skin"); + /// + /// "Current skin" + /// + public static LocalisableString CurrentSkin => new TranslatableString(getKey(@"current_skin"), @"Current skin"); + /// /// "Skin layout editor" /// diff --git a/osu.Game/Models/RealmBeatmap.cs b/osu.Game/Models/RealmBeatmap.cs new file mode 100644 index 0000000000..5049c1384d --- /dev/null +++ b/osu.Game/Models/RealmBeatmap.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using Newtonsoft.Json; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + /// + /// A single beatmap difficulty. + /// + [ExcludeFromDynamicCompile] + [Serializable] + [MapTo("Beatmap")] + public class RealmBeatmap : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public string DifficultyName { get; set; } = string.Empty; + + public RealmRuleset Ruleset { get; set; } = null!; + + public RealmBeatmapDifficulty Difficulty { get; set; } = null!; + + public RealmBeatmapMetadata Metadata { get; set; } = null!; + + public RealmBeatmapSet? BeatmapSet { get; set; } + + public BeatmapSetOnlineStatus Status + { + get => (BeatmapSetOnlineStatus)StatusInt; + set => StatusInt = (int)value; + } + + [MapTo(nameof(Status))] + public int StatusInt { get; set; } + + public int? OnlineID { get; set; } + + public double Length { get; set; } + + public double BPM { get; set; } + + public string Hash { get; set; } = string.Empty; + + public double StarRating { get; set; } + + public string MD5Hash { get; set; } = string.Empty; + + [JsonIgnore] + public bool Hidden { get; set; } + + public RealmBeatmap(RealmRuleset ruleset, RealmBeatmapDifficulty difficulty, RealmBeatmapMetadata metadata) + { + Ruleset = ruleset; + Difficulty = difficulty; + Metadata = metadata; + } + + [UsedImplicitly] + private RealmBeatmap() + { + } + + #region Properties we may not want persisted (but also maybe no harm?) + + public double AudioLeadIn { get; set; } + + public float StackLeniency { get; set; } = 0.7f; + + public bool SpecialStyle { get; set; } + + public bool LetterboxInBreaks { get; set; } + + public bool WidescreenStoryboard { get; set; } + + public bool EpilepsyWarning { get; set; } + + public bool SamplesMatchPlaybackRate { get; set; } + + public double DistanceSpacing { get; set; } + + public int BeatDivisor { get; set; } + + public int GridSize { get; set; } + + public double TimelineZoom { get; set; } + + #endregion + + public bool AudioEquals(RealmBeatmap? other) => other != null + && BeatmapSet != null + && other.BeatmapSet != null + && BeatmapSet.Hash == other.BeatmapSet.Hash + && Metadata.AudioFile == other.Metadata.AudioFile; + + public bool BackgroundEquals(RealmBeatmap? other) => other != null + && BeatmapSet != null + && other.BeatmapSet != null + && BeatmapSet.Hash == other.BeatmapSet.Hash + && Metadata.BackgroundFile == other.Metadata.BackgroundFile; + + IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; + IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; + IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; + IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => Difficulty; + } +} diff --git a/osu.Game/Models/RealmBeatmapDifficulty.cs b/osu.Game/Models/RealmBeatmapDifficulty.cs new file mode 100644 index 0000000000..3c1dad69e4 --- /dev/null +++ b/osu.Game/Models/RealmBeatmapDifficulty.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("BeatmapDifficulty")] + public class RealmBeatmapDifficulty : EmbeddedObject, IBeatmapDifficultyInfo + { + public float DrainRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float CircleSize { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float OverallDifficulty { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float ApproachRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + + public double SliderMultiplier { get; set; } = 1; + public double SliderTickRate { get; set; } = 1; + + /// + /// Returns a shallow-clone of this . + /// + public RealmBeatmapDifficulty Clone() + { + var diff = new RealmBeatmapDifficulty(); + CopyTo(diff); + return diff; + } + + public void CopyTo(RealmBeatmapDifficulty difficulty) + { + difficulty.ApproachRate = ApproachRate; + difficulty.DrainRate = DrainRate; + difficulty.CircleSize = CircleSize; + difficulty.OverallDifficulty = OverallDifficulty; + + difficulty.SliderMultiplier = SliderMultiplier; + difficulty.SliderTickRate = SliderTickRate; + } + } +} diff --git a/osu.Game/Models/RealmBeatmapMetadata.cs b/osu.Game/Models/RealmBeatmapMetadata.cs new file mode 100644 index 0000000000..6ea7170d0f --- /dev/null +++ b/osu.Game/Models/RealmBeatmapMetadata.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [Serializable] + [MapTo("BeatmapMetadata")] + public class RealmBeatmapMetadata : RealmObject, IBeatmapMetadataInfo + { + public string Title { get; set; } = string.Empty; + + [JsonProperty("title_unicode")] + public string TitleUnicode { get; set; } = string.Empty; + + public string Artist { get; set; } = string.Empty; + + [JsonProperty("artist_unicode")] + public string ArtistUnicode { get; set; } = string.Empty; + + public string Author { get; set; } = string.Empty; // eventually should be linked to a persisted User. + + public string Source { get; set; } = string.Empty; + + [JsonProperty(@"tags")] + public string Tags { get; set; } = string.Empty; + + /// + /// The time in milliseconds to begin playing the track for preview purposes. + /// If -1, the track should begin playing at 40% of its length. + /// + public int PreviewTime { get; set; } + + public string AudioFile { get; set; } = string.Empty; + public string BackgroundFile { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Models/RealmBeatmapSet.cs b/osu.Game/Models/RealmBeatmapSet.cs new file mode 100644 index 0000000000..314ca4494b --- /dev/null +++ b/osu.Game/Models/RealmBeatmapSet.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("BeatmapSet")] + public class RealmBeatmapSet : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IBeatmapSetInfo + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public int? OnlineID { get; set; } + + public DateTimeOffset DateAdded { get; set; } + + public IBeatmapMetadataInfo? Metadata => Beatmaps.FirstOrDefault()?.Metadata; + + public IList Beatmaps { get; } = null!; + + public IList Files { get; } = null!; + + public bool DeletePending { get; set; } + + public string Hash { get; set; } = string.Empty; + + /// + /// Whether deleting this beatmap set should be prohibited (due to it being a system requirement to be present). + /// + public bool Protected { get; set; } + + public double MaxStarDifficulty => Beatmaps.Max(b => b.StarRating); + + public double MaxLength => Beatmaps.Max(b => b.Length); + + public double MaxBPM => Beatmaps.Max(b => b.BPM); + + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// + /// The name of the file to get the storage path of. + public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.StoragePath; + + public override string ToString() => Metadata?.ToString() ?? base.ToString(); + + public bool Equals(RealmBeatmapSet? other) + { + if (other == null) + return false; + + if (IsManaged && other.IsManaged) + return ID == other.ID; + + if (OnlineID.HasValue && other.OnlineID.HasValue) + return OnlineID == other.OnlineID; + + if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) + return Hash == other.Hash; + + return ReferenceEquals(this, other); + } + + IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; + + IEnumerable IBeatmapSetInfo.Files => Files; + } +} diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs new file mode 100644 index 0000000000..2715f4be45 --- /dev/null +++ b/osu.Game/Models/RealmFile.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Testing; +using osu.Game.IO; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("File")] + public class RealmFile : RealmObject, IFileInfo + { + [PrimaryKey] + public string Hash { get; set; } = string.Empty; + + public string StoragePath => Path.Combine(Hash.Remove(1), Hash.Remove(2), Hash); + } +} diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs new file mode 100644 index 0000000000..ba12d51d0b --- /dev/null +++ b/osu.Game/Models/RealmNamedFileUsage.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.IO; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage + { + public RealmFile File { get; set; } = null!; + + public string Filename { get; set; } = null!; + + public RealmNamedFileUsage(RealmFile file, string filename) + { + File = file; + Filename = filename; + } + + [UsedImplicitly] + private RealmNamedFileUsage() + { + } + + IFileInfo INamedFileUsage.File => File; + } +} diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs new file mode 100644 index 0000000000..0dcd701ed2 --- /dev/null +++ b/osu.Game/Models/RealmRuleset.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using JetBrains.Annotations; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("Ruleset")] + public class RealmRuleset : RealmObject, IEquatable, IRulesetInfo + { + [PrimaryKey] + public string ShortName { get; set; } = string.Empty; + + public int? OnlineID { get; set; } + + public string Name { get; set; } = string.Empty; + + public string InstantiationInfo { get; set; } = string.Empty; + + public RealmRuleset(string shortName, string name, string instantiationInfo, int? onlineID = null) + { + ShortName = shortName; + Name = name; + InstantiationInfo = instantiationInfo; + OnlineID = onlineID; + } + + [UsedImplicitly] + private RealmRuleset() + { + } + + public RealmRuleset(int? onlineID, string name, string shortName, bool available) + { + OnlineID = onlineID; + Name = name; + ShortName = shortName; + Available = available; + } + + public bool Available { get; set; } + + public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; + + public override string ToString() => Name; + + public RealmRuleset Clone() => new RealmRuleset + { + OnlineID = OnlineID, + Name = Name, + ShortName = ShortName, + InstantiationInfo = InstantiationInfo, + Available = Available + }; + } +} diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 0e4ea694aa..201ba6239b 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -177,6 +177,24 @@ namespace osu.Game.Online.Chat case "wiki": return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3))); + + case "home": + if (mainArg != "changelog") + // handle link other than changelog as external for now + return new LinkDetails(LinkAction.External, url); + + switch (args.Length) + { + case 4: + // https://osu.ppy.sh/home/changelog + return new LinkDetails(LinkAction.OpenChangelog, string.Empty); + + case 6: + // https://osu.ppy.sh/home/changelog/lazer/2021.1006 + return new LinkDetails(LinkAction.OpenChangelog, $"{args[4]}/{args[5]}"); + } + + break; } } @@ -324,6 +342,7 @@ namespace osu.Game.Online.Chat SearchBeatmapSet, OpenWiki, Custom, + OpenChangelog, } public class Link : IComparable diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 8a018f17d9..020cdebab6 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -90,6 +90,8 @@ namespace osu.Game private WikiOverlay wikiOverlay; + private ChangelogOverlay changelogOverlay; + private SkinEditorOverlay skinEditor; private Container overlayContent; @@ -209,13 +211,6 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - if (args?.Length > 0) - { - var paths = args.Where(a => !a.StartsWith('-')).ToArray(); - if (paths.Length > 0) - Task.Run(() => Import(paths)); - } - dependencies.CacheAs(this); dependencies.Cache(SentryLogger); @@ -336,6 +331,17 @@ namespace osu.Game ShowWiki(link.Argument); break; + case LinkAction.OpenChangelog: + if (string.IsNullOrEmpty(link.Argument)) + ShowChangelogListing(); + else + { + var changelogArgs = link.Argument.Split("/"); + ShowChangelogBuild(changelogArgs[0], changelogArgs[1]); + } + + break; + default: throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); } @@ -401,6 +407,18 @@ namespace osu.Game /// The wiki page to show public void ShowWiki(string path) => waitForReady(() => wikiOverlay, _ => wikiOverlay.ShowPage(path)); + /// + /// Show changelog listing overlay + /// + public void ShowChangelogListing() => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowListing()); + + /// + /// Show changelog's build as an overlay + /// + /// The update stream name + /// The build version of the update stream + public void ShowChangelogBuild(string updateStream, string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(updateStream, version)); + /// /// Present a beatmap at song select immediately. /// The user should have already requested this interactively. @@ -769,7 +787,7 @@ namespace osu.Game loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); loadComponentSingleFile(new MessageNotifier(), AddInternal, true); loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true); - var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true); + loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); @@ -842,6 +860,19 @@ namespace osu.Game { if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; + + // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. + handleStartupImport(); + } + + private void handleStartupImport() + { + if (args?.Length > 0) + { + var paths = args.Where(a => !a.StartsWith('-')).ToArray(); + if (paths.Length > 0) + Task.Run(() => Import(paths)); + } } private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays) diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 7f4fe8a943..09eb482d16 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -410,11 +411,28 @@ namespace osu.Game { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); - using (realmFactory.BlockAllOperations()) + IDisposable realmBlocker = null; + + try { - contextFactory.FlushConnections(); + ManualResetEventSlim readyToRun = new ManualResetEventSlim(); + + Scheduler.Add(() => + { + realmBlocker = realmFactory.BlockAllOperations(); + contextFactory.FlushConnections(); + + readyToRun.Set(); + }, false); + + readyToRun.Wait(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); } + finally + { + realmBlocker?.Dispose(); + } Logger.Log(@"Migration complete!"); } diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 51214fe460..198aa1438a 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays.OSD private Sample sampleChange; public TrackedSettingToast(SettingDescription description) - : base(description.Name, description.Value, description.Shortcut) + : base(description.Name.ToString(), description.Value.ToString(), description.Shortcut.ToString()) { FillFlowContainer optionLights; diff --git a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs index c02db40eca..4ca3ace8a1 100644 --- a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs +++ b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs @@ -14,10 +14,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundColour = colours.Pink; - - Triangles.ColourDark = colours.PinkDark; - Triangles.ColourLight = colours.PinkLight; + BackgroundColour = colours.Pink3; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index d697b45424..0c54ae2763 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -28,6 +28,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { dropdown = new AudioDeviceSettingsDropdown { + LabelText = AudioSettingsStrings.OutputDevice, Keywords = new[] { "speaker", "headphone", "output" } } }; diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs new file mode 100644 index 0000000000..dba64d695a --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class AudioSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.AudioHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.PositionalHitsounds, + Current = config.GetBindable(OsuSetting.PositionalHitSounds) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, + Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs new file mode 100644 index 0000000000..94e0c5e494 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class BackgroundSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.BackgroundHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundDim, + Current = config.GetBindable(OsuSetting.DimLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundBlur, + Current = config.GetBindable(OsuSetting.BlurLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.LightenDuringBreaks, + Current = config.GetBindable(OsuSetting.LightenDuringBreaks) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, + Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs new file mode 100644 index 0000000000..aaa60ce81b --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class BeatmapSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.BeatmapHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapSkins, + Current = config.GetBindable(OsuSetting.BeatmapSkins) + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapColours, + Current = config.GetBindable(OsuSetting.BeatmapColours) + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapHitsounds, + Current = config.GetBindable(OsuSetting.BeatmapHitsounds) + }, + new SettingsCheckbox + { + LabelText = GraphicsSettingsStrings.StoryboardVideo, + Current = config.GetBindable(OsuSetting.ShowStoryboard) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 3a0265e453..d4e4fd571d 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -1,7 +1,6 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -20,77 +19,18 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { Children = new Drawable[] { - new SettingsSlider - { - LabelText = GameplaySettingsStrings.BackgroundDim, - Current = config.GetBindable(OsuSetting.DimLevel), - KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider - { - LabelText = GameplaySettingsStrings.BackgroundBlur, - Current = config.GetBindable(OsuSetting.BlurLevel), - KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.LightenDuringBreaks, - Current = config.GetBindable(OsuSetting.LightenDuringBreaks) - }, - new SettingsEnumDropdown - { - LabelText = GameplaySettingsStrings.HUDVisibilityMode, - Current = config.GetBindable(OsuSetting.HUDVisibilityMode) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.ShowDifficultyGraph, - Current = config.GetBindable(OsuSetting.ShowProgressGraph) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, - Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), - Keywords = new[] { "hp", "bar" } - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, - Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay, - Current = config.GetBindable(OsuSetting.KeyOverlay) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.PositionalHitsounds, - Current = config.GetBindable(OsuSetting.PositionalHitSounds) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, - Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) - }, new SettingsEnumDropdown { LabelText = GameplaySettingsStrings.ScoreDisplayMode, Current = config.GetBindable(OsuSetting.ScoreDisplayMode), Keywords = new[] { "scoring" } }, - }; - - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) - { - Add(new SettingsCheckbox + new SettingsCheckbox { - LabelText = GameplaySettingsStrings.DisableWinKey, - Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) - }); - } + LabelText = GraphicsSettingsStrings.HitLighting, + Current = config.GetBindable(OsuSetting.HitLighting) + }, + }; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs new file mode 100644 index 0000000000..e1b452e322 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class HUDSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.HUDHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsEnumDropdown + { + LabelText = GameplaySettingsStrings.HUDVisibilityMode, + Current = config.GetBindable(OsuSetting.HUDVisibilityMode) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.ShowDifficultyGraph, + Current = config.GetBindable(OsuSetting.ShowProgressGraph) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, + Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), + Keywords = new[] { "hp", "bar" } + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay, + Current = config.GetBindable(OsuSetting.KeyOverlay) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs new file mode 100644 index 0000000000..962572ca6e --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class InputSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.InputHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = SkinSettingsStrings.GameplayCursorSize, + Current = config.GetBindable(OsuSetting.GameplayCursorSize), + KeyboardStep = 0.01f + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.AutoCursorSize, + Current = config.GetBindable(OsuSetting.AutoCursorSize) + }, + }; + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + Add(new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.DisableWinKey, + Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) + }); + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index 42d9d48d73..120e2d908c 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -1,16 +1,11 @@ // Copyright (c) ppy Pty Ltd . 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.Graphics; -using osu.Game.Overlays.Settings.Sections.Gameplay; -using osu.Game.Rulesets; -using System.Linq; using osu.Framework.Graphics.Sprites; -using osu.Framework.Logging; using osu.Framework.Localisation; using osu.Game.Localisation; +using osu.Game.Overlays.Settings.Sections.Gameplay; namespace osu.Game.Overlays.Settings.Sections { @@ -20,7 +15,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Regular.Circle + Icon = FontAwesome.Regular.DotCircle }; public GameplaySection() @@ -28,27 +23,13 @@ namespace osu.Game.Overlays.Settings.Sections Children = new Drawable[] { new GeneralSettings(), + new AudioSettings(), + new BeatmapSettings(), + new BackgroundSettings(), + new HUDSettings(), + new InputSettings(), new ModsSettings(), }; } - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) - { - try - { - SettingsSubsection section = ruleset.CreateSettings(); - - if (section != null) - Add(section); - } - catch (Exception e) - { - Logger.Error(e, "Failed to load ruleset settings"); - } - } - } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs similarity index 67% rename from osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs rename to osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs index 20b1d8d801..dbb9ddc1c1 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs @@ -9,25 +9,15 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics { - public class DetailSettings : SettingsSubsection + public class ScreenshotSettings : SettingsSubsection { - protected override LocalisableString Header => GraphicsSettingsStrings.DetailSettingsHeader; + protected override LocalisableString Header => GraphicsSettingsStrings.Screenshots; [BackgroundDependencyLoader] private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox - { - LabelText = GraphicsSettingsStrings.StoryboardVideo, - Current = config.GetBindable(OsuSetting.ShowStoryboard) - }, - new SettingsCheckbox - { - LabelText = GraphicsSettingsStrings.HitLighting, - Current = config.GetBindable(OsuSetting.HitLighting) - }, new SettingsEnumDropdown { LabelText = GraphicsSettingsStrings.ScreenshotFormat, diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index fd0718f9f2..591848506a 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -22,9 +22,9 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { - new RendererSettings(), new LayoutSettings(), - new DetailSettings(), + new RendererSettings(), + new ScreenshotSettings(), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index cf8adf2785..da789db79a 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -78,7 +78,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private RealmContextFactory realmFactory { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -101,7 +101,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input EdgeEffect = new EdgeEffectParameters { Radius = 2, - Colour = colours.YellowDark.Opacity(0), + Colour = colourProvider.Highlight1.Opacity(0), Type = EdgeEffectType.Shadow, Hollow = true, }, @@ -110,13 +110,12 @@ namespace osu.Game.Overlays.Settings.Sections.Input new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.6f, + Colour = colourProvider.Background5, }, text = new OsuSpriteText { Text = action.GetLocalisableDescription(), - Margin = new MarginPadding(padding), + Margin = new MarginPadding(1.5f * padding), }, buttons = new FillFlowContainer { @@ -405,7 +404,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input private readonly Box box; public readonly OsuSpriteText Text; - private Color4 hoverColour; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } private bool isBinding; @@ -448,7 +448,6 @@ namespace osu.Game.Overlays.Settings.Sections.Input box = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black }, Text = new OsuSpriteText { @@ -463,9 +462,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - hoverColour = colours.YellowDark; + updateHoverState(); } protected override bool OnHover(HoverEvent e) @@ -484,12 +483,12 @@ namespace osu.Game.Overlays.Settings.Sections.Input { if (isBinding) { - box.FadeColour(Color4.White, transition_time, Easing.OutQuint); + box.FadeColour(colourProvider.Light2, transition_time, Easing.OutQuint); Text.FadeColour(Color4.Black, transition_time, Easing.OutQuint); } else { - box.FadeColour(IsHovered ? hoverColour : Color4.Black, transition_time, Easing.OutQuint); + box.FadeColour(IsHovered ? colourProvider.Light4 : colourProvider.Background6, transition_time, Easing.OutQuint); Text.FadeColour(IsHovered ? Color4.Black : Color4.White, transition_time, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index 2cc2857e9b..2051af6f3c 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Game.Database; -using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets; using osu.Game.Localisation; @@ -27,8 +26,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { this.variant = variant; - FlowContent.Spacing = new Vector2(0, 1); - FlowContent.Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; + FlowContent.Spacing = new Vector2(0, 3); } [BackgroundDependencyLoader] @@ -60,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - public class ResetButton : DangerousTriangleButton + public class ResetButton : DangerousSettingsButton { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs index 26610628d5..3ef5ce8941 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,16 +9,24 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Handlers.Tablet; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings.Sections.Input { - internal class RotationPresetButtons : FillFlowContainer + internal class RotationPresetButtons : CompositeDrawable { + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + private readonly ITabletHandler tabletHandler; private Bindable rotation; + private readonly RotationButton[] rotationPresets = new RotationButton[preset_count]; + private const int preset_count = 4; private const int height = 50; public RotationPresetButtons(ITabletHandler tabletHandler) @@ -27,18 +36,39 @@ namespace osu.Game.Overlays.Settings.Sections.Input RelativeSizeAxes = Axes.X; Height = height; - for (int i = 0; i < 360; i += 90) + IEnumerable createColumns(int count) { - var presetRotation = i; - - Add(new RotationButton(i) + for (int i = 0; i < count; ++i) { - RelativeSizeAxes = Axes.X, - Height = height, - Width = 0.25f, - Text = $@"{presetRotation}º", - Action = () => tabletHandler.Rotation.Value = presetRotation, - }); + if (i > 0) + yield return new Dimension(GridSizeMode.Absolute, 10); + + yield return new Dimension(); + } + } + + GridContainer grid; + + InternalChild = grid = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = createColumns(preset_count).ToArray() + }; + + grid.Content = new[] { new Drawable[preset_count * 2 - 1] }; + + for (int i = 0; i < preset_count; i++) + { + var rotationValue = i * 90; + + var rotationPreset = new RotationButton(rotationValue) + { + RelativeSizeAxes = Axes.Both, + Height = 1, + Text = $@"{rotationValue}º", + Action = () => tabletHandler.Rotation.Value = rotationValue, + }; + grid.Content[0][2 * i] = rotationPresets[i] = rotationPreset; } } @@ -49,16 +79,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input rotation = tabletHandler.Rotation.GetBoundCopy(); rotation.BindValueChanged(val => { - foreach (var b in Children.OfType()) + foreach (var b in rotationPresets) b.IsSelected = b.Preset == val.NewValue; }, true); } - public class RotationButton : TriangleButton + public class RotationButton : RoundedButton { [Resolved] private OsuColour colours { get; set; } + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + public readonly int Preset; public RotationButton(int preset) @@ -91,18 +124,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private void updateColour() { - if (isSelected) - { - BackgroundColour = colours.BlueDark; - Triangles.ColourDark = colours.BlueDarker; - Triangles.ColourLight = colours.Blue; - } - else - { - BackgroundColour = colours.Gray4; - Triangles.ColourDark = colours.Gray5; - Triangles.ColourLight = colours.Gray6; - } + BackgroundColour = isSelected ? colours.Blue3 : colourProvider.Background3; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 8c60e81fb5..c94b418331 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -165,7 +165,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input LabelText = TabletSettingsStrings.Rotation, Current = rotation }, - new RotationPresetButtons(tabletHandler), + new RotationPresetButtons(tabletHandler) + { + Padding = new MarginPadding + { + Horizontal = SettingsPanel.CONTENT_MARGINS + } + }, new SettingsSlider { TransferValueOnCommit = true, diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 803c8332c1..43df58a8b1 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -10,7 +10,6 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Database; -using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Scoring; using osu.Game.Skinning; @@ -21,15 +20,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { protected override LocalisableString Header => "General"; - private TriangleButton importBeatmapsButton; - private TriangleButton importScoresButton; - private TriangleButton importSkinsButton; - private TriangleButton importCollectionsButton; - private TriangleButton deleteBeatmapsButton; - private TriangleButton deleteScoresButton; - private TriangleButton deleteSkinsButton; - private TriangleButton restoreButton; - private TriangleButton undeleteButton; + private SettingsButton importBeatmapsButton; + private SettingsButton importScoresButton; + private SettingsButton importSkinsButton; + private SettingsButton importCollectionsButton; + private SettingsButton deleteBeatmapsButton; + private SettingsButton deleteScoresButton; + private SettingsButton deleteSkinsButton; + private SettingsButton restoreButton; + private SettingsButton undeleteButton; [BackgroundDependencyLoader(permitNulls: true)] private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay) diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index fa0c06167b..9410a87848 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.Maintenance; -using osuTK; namespace osu.Game.Overlays.Settings.Sections { @@ -21,7 +20,6 @@ namespace osu.Game.Overlays.Settings.Sections public MaintenanceSection() { - FlowContent.Spacing = new Vector2(0, 5); Children = new Drawable[] { new GeneralSettings() diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs new file mode 100644 index 0000000000..b9339d5299 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Rulesets; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections +{ + public class RulesetSection : SettingsSection + { + public override LocalisableString Header => RulesetSettingsStrings.Rulesets; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Chess + }; + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) + { + try + { + SettingsSubsection section = ruleset.CreateSettings(); + + if (section != null) + Add(section); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load ruleset settings"); + } + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index e0d8252930..00198235c5 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -16,7 +16,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Skinning; using osu.Game.Skinning.Editor; -using osuTK; namespace osu.Game.Overlays.Settings.Sections { @@ -63,43 +62,18 @@ namespace osu.Game.Overlays.Settings.Sections [BackgroundDependencyLoader(permitNulls: true)] private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) { - FlowContent.Spacing = new Vector2(0, 5); - Children = new Drawable[] { - skinDropdown = new SkinSettingsDropdown(), + skinDropdown = new SkinSettingsDropdown + { + LabelText = SkinSettingsStrings.CurrentSkin + }, new SettingsButton { Text = SkinSettingsStrings.SkinLayoutEditor, Action = () => skinEditor?.Toggle(), }, new ExportSkinButton(), - new SettingsSlider - { - LabelText = SkinSettingsStrings.GameplayCursorSize, - Current = config.GetBindable(OsuSetting.GameplayCursorSize), - KeyboardStep = 0.01f - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.AutoCursorSize, - Current = config.GetBindable(OsuSetting.AutoCursorSize) - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.BeatmapSkins, - Current = config.GetBindable(OsuSetting.BeatmapSkins) - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.BeatmapColours, - Current = config.GetBindable(OsuSetting.BeatmapColours) - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.BeatmapHitsounds, - Current = config.GetBindable(OsuSetting.BeatmapHitsounds) - }, }; managerUpdated = skins.ItemUpdated.GetBoundCopy(); diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 87b1aa0e46..be7f2de480 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -6,11 +6,11 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings { - public class SettingsButton : TriangleButton, IHasTooltip + public class SettingsButton : RoundedButton, IHasTooltip { public SettingsButton() { diff --git a/osu.Game/Overlays/Settings/SettingsHeader.cs b/osu.Game/Overlays/Settings/SettingsHeader.cs index a7f1cef74c..69b7b69a29 100644 --- a/osu.Game/Overlays/Settings/SettingsHeader.cs +++ b/osu.Game/Overlays/Settings/SettingsHeader.cs @@ -22,7 +22,7 @@ namespace osu.Game.Overlays.Settings } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Settings new OsuSpriteText { Text = heading, - Font = OsuFont.GetFont(size: 40), + Font = OsuFont.TorusAlternate.With(size: 40), Margin = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Settings }, new OsuSpriteText { - Colour = colours.Pink, + Colour = colourProvider.Content2, Text = subheading, Font = OsuFont.GetFont(size: 18), Margin = new MarginPadding diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index 2a6f3f5ed7..0ae353602e 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -12,7 +12,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK.Graphics; +using osuTK; namespace osu.Game.Overlays.Settings { @@ -31,9 +31,10 @@ namespace osu.Game.Overlays.Settings public IEnumerable FilterableChildren => Children.OfType(); public virtual IEnumerable FilterTerms => new[] { Header.ToString() }; - private const int header_size = 26; - private const int margin = 20; - private const int border_size = 2; + public const int ITEM_SPACING = 14; + + private const int header_size = 24; + private const int border_size = 4; public bool MatchingFilter { @@ -54,8 +55,9 @@ namespace osu.Game.Overlays.Settings { Margin = new MarginPadding { - Top = header_size + Top = 36 }, + Spacing = new Vector2(0, ITEM_SPACING), Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -63,14 +65,14 @@ namespace osu.Game.Overlays.Settings } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { AddRangeInternal(new Drawable[] { new Box { Name = "separator", - Colour = new Color4(0, 0, 0, 255), + Colour = colourProvider.Background6, RelativeSizeAxes = Axes.X, Height = border_size, }, @@ -78,8 +80,8 @@ namespace osu.Game.Overlays.Settings { Padding = new MarginPadding { - Top = margin + border_size, - Bottom = margin + 10, + Top = 28, + Bottom = 40, }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -87,13 +89,11 @@ namespace osu.Game.Overlays.Settings { header = new OsuSpriteText { - Font = OsuFont.GetFont(size: header_size), + Font = OsuFont.TorusAlternate.With(size: header_size), Text = Header, - Colour = colours.Yellow, Margin = new MarginPadding { - Left = SettingsPanel.CONTENT_MARGINS, - Right = SettingsPanel.CONTENT_MARGINS + Horizontal = SettingsPanel.CONTENT_MARGINS } }, FlowContent diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 4aa9360452..c2cf08ac98 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -46,13 +46,17 @@ namespace osu.Game.Overlays.Settings FlowContent = new FillFlowContainer { + Margin = new MarginPadding { Top = SettingsSection.ITEM_SPACING }, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 8), + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, }; } + private const int header_height = 43; + private const int header_font_size = 20; + [BackgroundDependencyLoader] private void load() { @@ -60,9 +64,9 @@ namespace osu.Game.Overlays.Settings { new OsuSpriteText { - Text = Header.ToString().ToUpper(), // TODO: Add localisation support after https://github.com/ppy/osu-framework/pull/4603 is merged. - Margin = new MarginPadding { Vertical = 30, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, - Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = Header, + Margin = new MarginPadding { Vertical = (header_height - header_font_size) * 0.5f, Horizontal = SettingsPanel.CONTENT_MARGINS }, + Font = OsuFont.GetFont(size: header_font_size), }, FlowContent }); diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index d28dbf1068..68562802cf 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Bindables; using osu.Framework.Graphics; namespace osu.Game.Overlays.Settings @@ -13,5 +15,17 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X, CommitOnFocusLost = true }; + + public override Bindable Current + { + get => base.Current; + set + { + if (value.Default == null) + throw new InvalidOperationException($"Bindable settings of type {nameof(Bindable)} should have a non-null default value."); + + base.Current = value; + } + } } } diff --git a/osu.Game/Overlays/Settings/Sidebar.cs b/osu.Game/Overlays/Settings/Sidebar.cs index 4ca6e2ec42..93b1b19b17 100644 --- a/osu.Game/Overlays/Settings/Sidebar.cs +++ b/osu.Game/Overlays/Settings/Sidebar.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,22 +16,23 @@ using osuTK; namespace osu.Game.Overlays.Settings { - public class Sidebar : Container, IStateful + public class Sidebar : Container, IStateful { - private readonly FillFlowContainer content; - public const float DEFAULT_WIDTH = Toolbar.Toolbar.HEIGHT * 1.4f; + private readonly Box background; + private readonly FillFlowContainer content; + public const float DEFAULT_WIDTH = 70; public const int EXPANDED_WIDTH = 200; public event Action StateChanged; - protected override Container Content => content; + protected override Container Content => content; public Sidebar() { RelativeSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - new Box + background = new Box { Colour = OsuColour.Gray(0.02f), RelativeSizeAxes = Axes.Both, @@ -39,7 +41,7 @@ namespace osu.Game.Overlays.Settings { Children = new[] { - content = new FillFlowContainer + content = new FillFlowContainer { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, @@ -52,6 +54,12 @@ namespace osu.Game.Overlays.Settings }; } + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background5; + } + private ScheduledDelegate expandEvent; private ExpandedState state; @@ -80,8 +88,6 @@ namespace osu.Game.Overlays.Settings { public SidebarScrollContainer() { - Content.Anchor = Anchor.CentreLeft; - Content.Origin = Anchor.CentreLeft; RelativeSizeAxes = Axes.Both; ScrollbarVisible = false; } diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index cf6a313a1f..1a34143e1f 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -1,110 +1,40 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { - public class SidebarButton : OsuButton + public abstract class SidebarButton : OsuButton { - private readonly ConstrainedIconContainer iconContainer; - private readonly SpriteText headerText; - private readonly Box selectionIndicator; - private readonly Container text; + protected const double FADE_DURATION = 500; - // always consider as part of flow, even when not visible (for the sake of the initial animation). - public override bool IsPresent => true; - - private SettingsSection section; - - public SettingsSection Section - { - get => section; - set - { - section = value; - headerText.Text = value.Header; - iconContainer.Icon = value.CreateIcon(); - } - } - - private bool selected; - - public bool Selected - { - get => selected; - set - { - selected = value; - - if (selected) - { - selectionIndicator.FadeIn(50); - text.FadeColour(Color4.White, 50); - } - else - { - selectionIndicator.FadeOut(50); - text.FadeColour(OsuColour.Gray(0.6f), 50); - } - } - } - - public SidebarButton() - { - Height = Sidebar.DEFAULT_WIDTH; - RelativeSizeAxes = Axes.X; - - BackgroundColour = Color4.Black; - - AddRange(new Drawable[] - { - text = new Container - { - Width = Sidebar.DEFAULT_WIDTH, - RelativeSizeAxes = Axes.Y, - Colour = OsuColour.Gray(0.6f), - Children = new Drawable[] - { - headerText = new OsuSpriteText - { - Position = new Vector2(Sidebar.DEFAULT_WIDTH + 10, 0), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - iconContainer = new ConstrainedIconContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(20), - }, - } - }, - selectionIndicator = new Box - { - Alpha = 0, - RelativeSizeAxes = Axes.Y, - Width = 5, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - }, - }); - } + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - selectionIndicator.Colour = colours.Yellow; + BackgroundColour = ColourProvider.Background5; } + + protected override void LoadComplete() + { + base.LoadComplete(); + UpdateState(); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateState(); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) => UpdateState(); + + protected abstract void UpdateState(); } } diff --git a/osu.Game/Overlays/Settings/SidebarIconButton.cs b/osu.Game/Overlays/Settings/SidebarIconButton.cs new file mode 100644 index 0000000000..fd57996b1b --- /dev/null +++ b/osu.Game/Overlays/Settings/SidebarIconButton.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Settings +{ + public class SidebarIconButton : SidebarButton + { + private const float selection_indicator_height_active = 18; + private const float selection_indicator_height_inactive = 4; + + private readonly ConstrainedIconContainer iconContainer; + private readonly SpriteText headerText; + private readonly CircularContainer selectionIndicator; + private readonly Container textIconContent; + + // always consider as part of flow, even when not visible (for the sake of the initial animation). + public override bool IsPresent => true; + + private SettingsSection section; + + public SettingsSection Section + { + get => section; + set + { + section = value; + headerText.Text = value.Header; + iconContainer.Icon = value.CreateIcon(); + } + } + + private bool selected; + + public bool Selected + { + get => selected; + set + { + selected = value; + + if (IsLoaded) + UpdateState(); + } + } + + public SidebarIconButton() + { + RelativeSizeAxes = Axes.X; + Height = 46; + + AddRange(new Drawable[] + { + textIconContent = new Container + { + Width = Sidebar.DEFAULT_WIDTH, + RelativeSizeAxes = Axes.Y, + Colour = OsuColour.Gray(0.6f), + Children = new Drawable[] + { + headerText = new OsuSpriteText + { + Position = new Vector2(Sidebar.DEFAULT_WIDTH + 10, 0), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + iconContainer = new ConstrainedIconContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20), + }, + } + }, + selectionIndicator = new CircularContainer + { + Alpha = 0, + Width = 4, + Height = selection_indicator_height_inactive, + Masking = true, + CornerRadius = 1.5f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding + { + Left = 9, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White + } + }, + }); + } + + [BackgroundDependencyLoader] + private void load() + { + selectionIndicator.Colour = ColourProvider.Highlight1; + } + + protected override void UpdateState() + { + if (Selected) + { + textIconContent.FadeColour(ColourProvider.Content1, FADE_DURATION, Easing.OutQuint); + + selectionIndicator.FadeIn(FADE_DURATION, Easing.OutQuint); + selectionIndicator.ResizeHeightTo(selection_indicator_height_active, FADE_DURATION, Easing.OutElasticHalf); + } + else + { + textIconContent.FadeColour(IsHovered ? ColourProvider.Light1 : ColourProvider.Light3, FADE_DURATION, Easing.OutQuint); + + selectionIndicator.FadeOut(FADE_DURATION, Easing.OutQuint); + selectionIndicator.ResizeHeightTo(selection_indicator_height_inactive, FADE_DURATION, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 55e8aee266..c84cba8189 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -24,12 +24,13 @@ namespace osu.Game.Overlays protected override IEnumerable CreateSections() => new SettingsSection[] { new GeneralSection(), - new GraphicsSection(), - new AudioSection(), + new SkinSection(), new InputSection(createSubPanel(new KeyBindingPanel())), new UserInterfaceSection(), new GameplaySection(), - new SkinSection(), + new RulesetSection(), + new AudioSection(), + new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), new DebugSection(), diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index bda4bb5ece..0ceb7fc50d 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; @@ -15,7 +14,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; @@ -25,7 +23,7 @@ namespace osu.Game.Overlays [Cached] public abstract class SettingsPanel : OsuFocusedOverlayContainer { - public const float CONTENT_MARGINS = 15; + public const float CONTENT_MARGINS = 20; public const float TRANSITION_LENGTH = 600; @@ -46,7 +44,7 @@ namespace osu.Game.Overlays protected override Container Content => ContentContainer; protected Sidebar Sidebar; - private SidebarButton selectedSidebarButton; + private SidebarIconButton selectedSidebarButton; public SettingsSectionsContainer SectionsContainer { get; private set; } @@ -64,6 +62,9 @@ namespace osu.Game.Overlays public IBindable CurrentSection = new Bindable(); + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + protected SettingsPanel(bool showSidebar) { this.showSidebar = showSidebar; @@ -89,7 +90,7 @@ namespace osu.Game.Overlays Origin = Anchor.TopRight, Scale = new Vector2(2, 1), // over-extend to the left for transitions RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.05f), + Colour = colourProvider.Background4, Alpha = 1, }, loading = new LoadingLayer @@ -105,17 +106,23 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, ExpandableHeader = CreateHeader(), SelectedSection = { BindTarget = CurrentSection }, - FixedHeader = searchTextBox = new SeekLimitedSearchTextBox + FixedHeader = new Container { RelativeSizeAxes = Axes.X, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Width = 0.95f, - Margin = new MarginPadding + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { - Top = 20, - Bottom = 20 + Vertical = 20, + Horizontal = CONTENT_MARGINS }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = searchTextBox = new SeekLimitedSearchTextBox + { + RelativeSizeAxes = Axes.X, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + } }, Footer = CreateFooter().With(f => f.Alpha = 0) }); @@ -245,11 +252,11 @@ namespace osu.Game.Overlays }); } - private IEnumerable createSidebarButtons() + private IEnumerable createSidebarButtons() { foreach (var section in SectionsContainer) { - yield return new SidebarButton + yield return new SidebarIconButton { Section = section, Action = () => @@ -292,11 +299,12 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, }; - public SettingsSectionsContainer() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { HeaderBackground = new Box { - Colour = Color4.Black, + Colour = colourProvider.Background4, RelativeSizeAxes = Axes.Both }; } diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index 1fa233d9d4..a65d792a9f 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -7,10 +7,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays { @@ -34,18 +32,18 @@ namespace osu.Game.Overlays protected override bool DimMainContent => false; // dimming is handled by main overlay - private class BackButton : OsuButton + private class BackButton : SidebarButton { + private Container content; + [BackgroundDependencyLoader] private void load() { Size = new Vector2(Sidebar.DEFAULT_WIDTH); - BackgroundColour = Color4.Black; - AddRange(new Drawable[] { - new Container + content = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -71,6 +69,11 @@ namespace osu.Game.Overlays } }); } + + protected override void UpdateState() + { + content.FadeColour(IsHovered ? ColourProvider.Light1 : ColourProvider.Light3, FADE_DURATION, Easing.OutQuint); + } } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs index 73bab31e82..d8babf2f32 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// The current strain level. /// - protected double CurrentStrain { get; private set; } = 1; + protected double CurrentStrain { get; private set; } protected StrainDecaySkill(Mod[] mods) : base(mods) diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 0880f1b08e..bbd2f079aa 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// protected virtual int SectionLength => 400; - private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. + private double currentSectionPeak; // We also keep track of the peak strain level in the current section. private double currentSectionEnd; diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index e9865f6c8b..c0b339a231 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -55,7 +55,10 @@ namespace osu.Game.Rulesets.UI /// /// The current direction of playback to be exposed to frame stable children. /// - private int direction; + /// + /// Initially it is presumed that playback will proceed in the forward direction. + /// + private int direction = 1; [BackgroundDependencyLoader(true)] private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler) @@ -139,7 +142,9 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.NotValid; } - if (state == PlaybackState.Valid) + // if the proposed time is the same as the current time, assume that the clock will continue progressing in the same direction as previously. + // this avoids spurious flips in direction from -1 to 1 during rewinds. + if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime) direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index dde956233b..cf22a8fda4 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -72,9 +72,12 @@ namespace osu.Game.Scoring } } - // We're calling .Result, but this should not be a blocking call due to the above GetDifficultyAsync() calls. - return scores.OrderByDescending(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken).Result) - .ThenBy(s => s.OnlineScoreID) + var totalScores = await Task.WhenAll(scores.Select(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken))).ConfigureAwait(false); + + return scores.Select((score, index) => (score, totalScore: totalScores[index])) + .OrderByDescending(g => g.totalScore) + .ThenBy(g => g.score.OnlineScoreID) + .Select(g => g.score) .ToArray(); } diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index e250791b72..ea158c5789 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -22,7 +22,7 @@ namespace osu.Game.Screens.Play { /// /// Manage the animation to be applied when a player fails. - /// Single file; automatically disposed after use. + /// Single use and automatically disposed after use. /// public class FailAnimation : CompositeDrawable { diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 63de5c8de5..87b19e8433 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -11,7 +11,6 @@ namespace osu.Game.Screens.Play.HUD public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable { public DefaultScoreCounter() - : base(6) { Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; diff --git a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index e09630d2c4..e05eff5f3e 100644 --- a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -14,8 +14,8 @@ namespace osu.Game.Screens.Play.HUD { private Bindable scoreDisplayMode; - protected GameplayScoreCounter(int leading = 0, bool useCommaSeparator = false) - : base(leading, useCommaSeparator) + protected GameplayScoreCounter() + : base(6) { } diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs index d04e60a2ab..b1c07512dd 100644 --- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs +++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs @@ -4,11 +4,9 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -148,7 +146,7 @@ namespace osu.Game.Screens.Play.HUD Score2Text.X = Math.Max(5 + Score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - protected class MatchScoreCounter : ScoreCounter + protected class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; @@ -173,8 +171,6 @@ namespace osu.Game.Screens.Play.HUD => displayedSpriteText.Font = winning ? OsuFont.Torus.With(weight: FontWeight.Bold, size: font_size, fixedWidth: true) : OsuFont.Torus.With(weight: FontWeight.Regular, size: font_size * 0.8f, fixedWidth: true); - - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N0"); } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 090210e611..444bea049b 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -947,7 +947,7 @@ namespace osu.Game.Screens.Play public override void OnSuspending(IScreen next) { - screenSuspension?.Expire(); + screenSuspension?.RemoveAndDisposeImmediately(); fadeOut(); base.OnSuspending(next); @@ -955,7 +955,8 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { - screenSuspension?.Expire(); + screenSuspension?.RemoveAndDisposeImmediately(); + failAnimation?.RemoveAndDisposeImmediately(); // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. if (prepareScoreForDisplayTask == null) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 94a61a4ef3..cf5bff57cf 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -35,6 +35,8 @@ namespace osu.Game.Screens.Play { protected const float BACKGROUND_BLUR = 15; + private const double content_out_duration = 300; + public override bool HideOverlaysOnEnter => hideOverlays; public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -135,36 +137,39 @@ namespace osu.Game.Screens.Play muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); - InternalChild = (content = new LogoTrackingContainer + InternalChildren = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - }).WithChildren(new Drawable[] - { - MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) + (content = new LogoTrackingContainer { - Alpha = 0, Anchor = Anchor.Centre, Origin = Anchor.Centre, - }, - PlayerSettings = new FillFlowContainer + RelativeSizeAxes = Axes.Both, + }).WithChildren(new Drawable[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Margin = new MarginPadding(25), - Children = new PlayerSettingsGroup[] + MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) { - VisualSettings = new VisualSettings(), - new InputSettings() - } - }, - idleTracker = new IdleTracker(750), + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + PlayerSettings = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Margin = new MarginPadding(25), + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + new InputSettings() + } + }, + idleTracker = new IdleTracker(750), + }), lowPassFilter = new AudioFilter(audio.TrackMixer) - }); + }; if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) { @@ -195,7 +200,6 @@ namespace osu.Game.Screens.Play epilepsyWarning.DimmableBackground = b; }); - lowPassFilter.CutoffTo(500, 100, Easing.OutCubic); Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); content.ScaleTo(0.7f); @@ -240,15 +244,15 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { cancelLoad(); + contentOut(); - content.ScaleTo(0.7f, 150, Easing.InQuint); - this.FadeOut(150); + // Ensure the screen doesn't expire until all the outwards fade operations have completed. + this.Delay(content_out_duration).FadeOut(); ApplyToBackground(b => b.IgnoreUserSettings.Value = true); BackgroundBrightnessReduction = false; Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); return base.OnExiting(next); } @@ -344,6 +348,7 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); } @@ -353,8 +358,9 @@ namespace osu.Game.Screens.Play // Ensure the logo is no longer tracking before we scale the content content.StopTracking(); - content.ScaleTo(0.7f, 300, Easing.InQuint); - content.FadeOut(250); + content.ScaleTo(0.7f, content_out_duration * 2, Easing.OutQuint); + content.FadeOut(content_out_duration, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, content_out_duration); } private void pushWhenLoaded() @@ -381,7 +387,7 @@ namespace osu.Game.Screens.Play contentOut(); - TransformSequence pushSequence = this.Delay(250); + TransformSequence pushSequence = this.Delay(content_out_duration); // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). @@ -400,6 +406,11 @@ namespace osu.Game.Screens.Play }) .Delay(EpilepsyWarning.FADE_DURATION); } + else + { + // This goes hand-in-hand with the restoration of low pass filter in contentOut(). + this.TransformBindableTo(volumeAdjustment, 0, content_out_duration, Easing.OutCubic); + } pushSequence.Schedule(() => { diff --git a/osu.Game/Skinning/ISkinSource.cs b/osu.Game/Skinning/ISkinSource.cs index ba3e2bf6ad..a5ed0fc990 100644 --- a/osu.Game/Skinning/ISkinSource.cs +++ b/osu.Game/Skinning/ISkinSource.cs @@ -12,6 +12,9 @@ namespace osu.Game.Skinning /// public interface ISkinSource : ISkin { + /// + /// Fired whenever a source change occurs, signalling that consumers should re-query as required. + /// event Action SourceChanged; /// diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index a12defe87e..0c9a82074f 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -16,7 +16,6 @@ namespace osu.Game.Skinning public bool UsesFixedAnchor { get; set; } public LegacyScoreCounter() - : base(6) { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index f5a7788359..b884794739 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -58,10 +58,8 @@ namespace osu.Game.Skinning return base.CreateChildDependencies(parent); } - protected override void OnSourceChanged() + protected override void RefreshSources() { - ResetSources(); - // Populate a local list first so we can adjust the returned order as we go. var sources = new List(); @@ -91,8 +89,7 @@ namespace osu.Game.Skinning else sources.Add(rulesetResourcesSkin); - foreach (var skin in sources) - AddSource(skin); + SetSources(sources); } protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin) diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index ada6e4b788..c8e4c2c7b6 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -40,10 +41,12 @@ namespace osu.Game.Skinning protected virtual bool AllowColourLookup => true; + private readonly object sourceSetLock = new object(); + /// /// A dictionary mapping each source to a wrapper which handles lookup allowances. /// - private readonly List<(ISkin skin, DisableableSkinSource wrapped)> skinSources = new List<(ISkin, DisableableSkinSource)>(); + private (ISkin skin, DisableableSkinSource wrapped)[] skinSources = Array.Empty<(ISkin skin, DisableableSkinSource wrapped)>(); /// /// Constructs a new initialised with a single skin source. @@ -52,7 +55,7 @@ namespace osu.Game.Skinning : this() { if (skin != null) - AddSource(skin); + SetSources(new[] { skin }); } /// @@ -168,49 +171,42 @@ namespace osu.Game.Skinning } /// - /// Add a new skin to this provider. Will be added to the end of the lookup order precedence. + /// Replace the sources used for lookups in this container. /// - /// The skin to add. - protected void AddSource(ISkin skin) + /// + /// This does not implicitly fire a event. Consider calling if required. + /// + /// The new sources. + protected void SetSources(IEnumerable sources) { - skinSources.Add((skin, new DisableableSkinSource(skin, this))); + lock (sourceSetLock) + { + foreach (var skin in skinSources) + { + if (skin.skin is ISkinSource source) + source.SourceChanged -= TriggerSourceChanged; + } - if (skin is ISkinSource source) - source.SourceChanged += TriggerSourceChanged; + skinSources = sources.Select(skin => (skin, new DisableableSkinSource(skin, this))).ToArray(); + + foreach (var skin in skinSources) + { + if (skin.skin is ISkinSource source) + source.SourceChanged += TriggerSourceChanged; + } + } } /// - /// Remove a skin from this provider. - /// - /// The skin to remove. - protected void RemoveSource(ISkin skin) - { - if (skinSources.RemoveAll(s => s.skin == skin) == 0) - return; - - if (skin is ISkinSource source) - source.SourceChanged -= TriggerSourceChanged; - } - - /// - /// Clears all skin sources. - /// - protected void ResetSources() - { - foreach (var i in skinSources.ToArray()) - RemoveSource(i.skin); - } - - /// - /// Invoked when any source has changed (either or a source registered via ). + /// Invoked after any consumed source change, before the external event is fired. /// This is also invoked once initially during to ensure sources are ready for children consumption. /// - protected virtual void OnSourceChanged() { } + protected virtual void RefreshSources() { } protected void TriggerSourceChanged() { // Expose to implementations, giving them a chance to react before notifying external consumers. - OnSourceChanged(); + RefreshSources(); SourceChanged?.Invoke(); } diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs index e7125bb034..20c2fcc075 100644 --- a/osu.Game/Skinning/SkinnableTargetContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -22,6 +23,8 @@ namespace osu.Game.Skinning public bool ComponentsLoaded { get; private set; } + private CancellationTokenSource cancellationSource; + public SkinnableTargetContainer(SkinnableTarget target) { Target = target; @@ -38,6 +41,9 @@ namespace osu.Game.Skinning content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; + cancellationSource?.Cancel(); + cancellationSource = null; + if (content != null) { LoadComponentAsync(content, wrapper => @@ -45,7 +51,7 @@ namespace osu.Game.Skinning AddInternal(wrapper); components.AddRange(wrapper.Children.OfType()); ComponentsLoaded = true; - }); + }, (cancellationSource = new CancellationTokenSource()).Token); } else ComponentsLoaded = true; diff --git a/osu.Game/Stores/RealmFileStore.cs b/osu.Game/Stores/RealmFileStore.cs new file mode 100644 index 0000000000..f7b7471634 --- /dev/null +++ b/osu.Game/Stores/RealmFileStore.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Models; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Handles the storing of files to the file system (and database) backing. + /// + [ExcludeFromDynamicCompile] + public class RealmFileStore + { + private readonly RealmContextFactory realmFactory; + + public readonly IResourceStore Store; + + public readonly Storage Storage; + + public RealmFileStore(RealmContextFactory realmFactory, Storage storage) + { + this.realmFactory = realmFactory; + + Storage = storage.GetStorageForDirectory(@"files"); + Store = new StorageBackedResourceStore(Storage); + } + + /// + /// Add a new file to the game-wide database, copying it to permanent storage if not already present. + /// + /// The file data stream. + /// The realm instance to add to. Should already be in a transaction. + /// + public RealmFile Add(Stream data, Realm realm) + { + string hash = data.ComputeSHA2Hash(); + + var existing = realm.Find(hash); + + var file = existing ?? new RealmFile { Hash = hash }; + + if (!checkFileExistsAndMatchesHash(file)) + copyToStore(file, data); + + if (!file.IsManaged) + realm.Add(file); + + return file; + } + + private void copyToStore(RealmFile file, Stream data) + { + data.Seek(0, SeekOrigin.Begin); + + using (var output = Storage.GetStream(file.StoragePath, FileAccess.Write)) + data.CopyTo(output); + + data.Seek(0, SeekOrigin.Begin); + } + + private bool checkFileExistsAndMatchesHash(RealmFile file) + { + string path = file.StoragePath; + + // we may be re-adding a file to fix missing store entries. + if (!Storage.Exists(path)) + return false; + + // even if the file already exists, check the existing checksum for safety. + using (var stream = Storage.GetStream(path)) + return stream.ComputeSHA2Hash() == file.Hash; + } + + public void Cleanup() + { + var realm = realmFactory.Context; + + // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. + using (var transaction = realm.BeginWrite()) + { + // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707) + var files = realm.All().ToList(); + + foreach (var file in files) + { + if (file.BacklinksCount > 0) + continue; + + try + { + Storage.Delete(file.StoragePath); + realm.Remove(file); + } + catch (Exception e) + { + Logger.Error(e, $@"Could not delete databased file {file.Hash}"); + } + } + + transaction.Commit(); + } + } + } +} diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 77db697cb6..6a11bd3fea 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -78,9 +78,11 @@ namespace osu.Game.Tests.Visual protected void CreateGame() { - AddGame(Game = new TestOsuGame(LocalStorage, API)); + AddGame(Game = CreateTestGame()); } + protected virtual TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API); + protected void PushAndConfirm(Func newScreen) { Screen screen = null; @@ -135,7 +137,8 @@ namespace osu.Game.Tests.Visual public new void PerformFromScreen(Action action, IEnumerable validScreens = null) => base.PerformFromScreen(action, validScreens); - public TestOsuGame(Storage storage, IAPIProvider api) + public TestOsuGame(Storage storage, IAPIProvider api, string[] args = null) + : base(args) { Storage = storage; API = api; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4877ddf725..184c9d3f63 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index edce9d27fe..38b920420b 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -93,7 +93,7 @@ - +