Merge branch 'master' into move-many-slider-nodes-at-once

This commit is contained in:
Dean Herbert
2021-12-23 14:11:04 +09:00
committed by GitHub
73 changed files with 1788 additions and 1216 deletions

View File

@ -52,7 +52,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1215.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1220.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1221.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->

View File

@ -14,6 +14,7 @@ namespace osu.Desktop.Windows
{
private Bindable<bool> disableWinKey;
private IBindable<bool> localUserPlaying;
private IBindable<bool> isActive;
[Resolved]
private GameHost host { get; set; }
@ -24,13 +25,16 @@ namespace osu.Desktop.Windows
localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy();
localUserPlaying.BindValueChanged(_ => updateBlocking());
isActive = host.IsActive.GetBoundCopy();
isActive.BindValueChanged(_ => updateBlocking());
disableWinKey = config.GetBindable<bool>(OsuSetting.GameplayDisableWinKey);
disableWinKey.BindValueChanged(_ => updateBlocking(), true);
}
private void updateBlocking()
{
bool shouldDisable = disableWinKey.Value && localUserPlaying.Value;
bool shouldDisable = isActive.Value && disableWinKey.Value && localUserPlaying.Value;
if (shouldDisable)
host.InputThread.Scheduler.Add(WindowsKey.Disable);

View File

@ -0,0 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Difficulty;
namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchPerformanceAttributes : PerformanceAttributes
{
}
}

View File

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
}
public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
public override PerformanceAttributes Calculate()
{
mods = Score.Mods;
@ -44,15 +44,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// Longer maps are worth more. "Longer" means how many hits there are which can contribute to combo
int numTotalHits = totalComboHits();
// Longer maps are worth more
double lengthBonus =
0.95 + 0.3 * Math.Min(1.0, numTotalHits / 2500.0) +
(numTotalHits > 2500 ? Math.Log10(numTotalHits / 2500.0) * 0.475 : 0.0);
// Longer maps are worth more
value *= lengthBonus;
// Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available
value *= Math.Pow(0.97, misses);
// Combo scaling
@ -80,17 +76,17 @@ namespace osu.Game.Rulesets.Catch.Difficulty
}
if (mods.Any(m => m is ModFlashlight))
// Apply length bonus again if flashlight is on simply because it becomes a lot harder on longer maps.
value *= 1.35 * lengthBonus;
// Scale the aim value with accuracy _slightly_
value *= Math.Pow(accuracy(), 5.5);
// Custom multipliers for NoFail. SpunOut is not applicable.
if (mods.Any(m => m is ModNoFail))
value *= 0.90;
return value;
return new CatchPerformanceAttributes
{
Total = value
};
}
private double accuracy() => totalHits() == 0 ? 0 : Math.Clamp((double)totalSuccessfulHits() / totalHits(), 0, 1);

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
yield return v;
// Todo: osu!mania doesn't output MaxCombo attribute for some reason.
yield return (ATTRIB_ID_STRAIN, StarRating);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
yield return (ATTRIB_ID_SCORE_MULTIPLIER, ScoreMultiplier);
}
@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
base.FromDatabaseAttributes(values);
StarRating = values[ATTRIB_ID_STRAIN];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
ScoreMultiplier = values[ATTRIB_ID_SCORE_MULTIPLIER];
}

View File

@ -0,0 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using osu.Game.Rulesets.Difficulty;
namespace osu.Game.Rulesets.Mania.Difficulty
{
public class ManiaPerformanceAttributes : PerformanceAttributes
{
[JsonProperty("difficulty")]
public double Difficulty { get; set; }
[JsonProperty("accuracy")]
public double Accuracy { get; set; }
[JsonProperty("scaled_score")]
public double ScaledScore { get; set; }
}
}

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
}
public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
public override PerformanceAttributes Calculate()
{
mods = Score.Mods;
scaledScore = Score.TotalScore;
@ -61,48 +61,46 @@ namespace osu.Game.Rulesets.Mania.Difficulty
if (mods.Any(m => m is ModEasy))
multiplier *= 0.5;
double strainValue = computeStrainValue();
double accValue = computeAccuracyValue(strainValue);
double difficultyValue = computeDifficultyValue();
double accValue = computeAccuracyValue(difficultyValue);
double totalValue =
Math.Pow(
Math.Pow(strainValue, 1.1) +
Math.Pow(difficultyValue, 1.1) +
Math.Pow(accValue, 1.1), 1.0 / 1.1
) * multiplier;
if (categoryDifficulty != null)
return new ManiaPerformanceAttributes
{
categoryDifficulty["Strain"] = strainValue;
categoryDifficulty["Accuracy"] = accValue;
}
return totalValue;
Difficulty = difficultyValue,
Accuracy = accValue,
ScaledScore = scaledScore,
Total = totalValue
};
}
private double computeStrainValue()
private double computeDifficultyValue()
{
// Obtain strain difficulty
double strainValue = Math.Pow(5 * Math.Max(1, Attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0;
double difficultyValue = Math.Pow(5 * Math.Max(1, Attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0;
// Longer maps are worth more
strainValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
difficultyValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
if (scaledScore <= 500000)
strainValue = 0;
difficultyValue = 0;
else if (scaledScore <= 600000)
strainValue *= (scaledScore - 500000) / 100000 * 0.3;
difficultyValue *= (scaledScore - 500000) / 100000 * 0.3;
else if (scaledScore <= 700000)
strainValue *= 0.3 + (scaledScore - 600000) / 100000 * 0.25;
difficultyValue *= 0.3 + (scaledScore - 600000) / 100000 * 0.25;
else if (scaledScore <= 800000)
strainValue *= 0.55 + (scaledScore - 700000) / 100000 * 0.20;
difficultyValue *= 0.55 + (scaledScore - 700000) / 100000 * 0.20;
else if (scaledScore <= 900000)
strainValue *= 0.75 + (scaledScore - 800000) / 100000 * 0.15;
difficultyValue *= 0.75 + (scaledScore - 800000) / 100000 * 0.15;
else
strainValue *= 0.90 + (scaledScore - 900000) / 100000 * 0.1;
difficultyValue *= 0.90 + (scaledScore - 900000) / 100000 * 0.1;
return strainValue;
return difficultyValue;
}
private double computeAccuracyValue(double strainValue)
private double computeAccuracyValue(double difficultyValue)
{
if (Attributes.GreatHitWindow <= 0)
return 0;
@ -110,12 +108,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
// Lots of arbitrary values from testing.
// Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
double accuracyValue = Math.Max(0.0, 0.2 - (Attributes.GreatHitWindow - 34) * 0.006667)
* strainValue
* difficultyValue
* Math.Pow(Math.Max(0.0, scaledScore - 960000) / 40000, 1.1);
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer
// accuracyValue *= Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
return accuracyValue;
}

View File

@ -12,14 +12,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuDifficultyAttributes : DifficultyAttributes
{
[JsonProperty("aim_strain")]
public double AimStrain { get; set; }
[JsonProperty("aim_difficulty")]
public double AimDifficulty { get; set; }
[JsonProperty("speed_strain")]
public double SpeedStrain { get; set; }
[JsonProperty("speed_difficulty")]
public double SpeedDifficulty { get; set; }
[JsonProperty("flashlight_rating")]
public double FlashlightRating { get; set; }
[JsonProperty("flashlight_difficulty")]
public double FlashlightDifficulty { get; set; }
[JsonProperty("slider_factor")]
public double SliderFactor { get; set; }
@ -43,15 +43,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty
foreach (var v in base.ToDatabaseAttributes())
yield return v;
yield return (ATTRIB_ID_AIM, AimStrain);
yield return (ATTRIB_ID_SPEED, SpeedStrain);
yield return (ATTRIB_ID_AIM, AimDifficulty);
yield return (ATTRIB_ID_SPEED, SpeedDifficulty);
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_STRAIN, StarRating);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
if (ShouldSerializeFlashlightRating())
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightRating);
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor);
}
@ -60,13 +60,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
base.FromDatabaseAttributes(values);
AimStrain = values[ATTRIB_ID_AIM];
SpeedStrain = values[ATTRIB_ID_SPEED];
AimDifficulty = values[ATTRIB_ID_AIM];
SpeedDifficulty = values[ATTRIB_ID_SPEED];
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_STRAIN];
FlashlightRating = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
}

View File

@ -74,9 +74,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
StarRating = starRating,
Mods = mods,
AimStrain = aimRating,
SpeedStrain = speedRating,
FlashlightRating = flashlightRating,
AimDifficulty = aimRating,
SpeedDifficulty = speedRating,
FlashlightDifficulty = flashlightRating,
SliderFactor = sliderFactor,
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6,

View File

@ -0,0 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using osu.Game.Rulesets.Difficulty;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuPerformanceAttributes : PerformanceAttributes
{
[JsonProperty("aim")]
public double Aim { get; set; }
[JsonProperty("speed")]
public double Speed { get; set; }
[JsonProperty("accuracy")]
public double Accuracy { get; set; }
[JsonProperty("flashlight")]
public double Flashlight { get; set; }
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
}
}

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
}
public override double Calculate(Dictionary<string, double> categoryRatings = null)
public override PerformanceAttributes Calculate()
{
mods = Score.Mods;
accuracy = Score.Accuracy;
@ -45,7 +45,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
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 * effectiveMissCount);
@ -72,42 +71,35 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Math.Pow(flashlightValue, 1.1), 1.0 / 1.1
) * multiplier;
if (categoryRatings != null)
return new OsuPerformanceAttributes
{
categoryRatings.Add("Aim", aimValue);
categoryRatings.Add("Speed", speedValue);
categoryRatings.Add("Accuracy", accuracyValue);
categoryRatings.Add("Flashlight", flashlightValue);
categoryRatings.Add("OD", Attributes.OverallDifficulty);
categoryRatings.Add("AR", Attributes.ApproachRate);
categoryRatings.Add("Max Combo", Attributes.MaxCombo);
}
return totalValue;
Aim = aimValue,
Speed = speedValue,
Accuracy = accuracyValue,
Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount,
Total = totalValue
};
}
private double computeAimValue()
{
double rawAim = Attributes.AimStrain;
double rawAim = Attributes.AimDifficulty;
if (mods.Any(m => m is OsuModTouchDevice))
rawAim = Math.Pow(rawAim, 0.8);
double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0;
// Longer maps are worth more.
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
aimValue *= lengthBonus;
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
aimValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), effectiveMissCount);
// Combo scaling.
if (Attributes.MaxCombo > 0)
aimValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0);
aimValue *= getComboScalingFactor();
double approachRateFactor = 0.0;
if (Attributes.ApproachRate > 10.33)
@ -136,7 +128,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
}
aimValue *= accuracy;
// It is important to also consider accuracy difficulty when doing that.
// It is important to consider accuracy difficulty when scaling with accuracy.
aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500;
return aimValue;
@ -144,9 +136,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double computeSpeedValue()
{
double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedStrain / 0.0675) - 4.0, 3.0) / 100000.0;
double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0;
// Longer maps are worth more.
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
speedValue *= lengthBonus;
@ -155,9 +146,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (effectiveMissCount > 0)
speedValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
// Combo scaling.
if (Attributes.MaxCombo > 0)
speedValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0);
speedValue *= getComboScalingFactor();
double approachRateFactor = 0.0;
if (Attributes.ApproachRate > 10.33)
@ -227,14 +216,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (!mods.Any(h => h is OsuModFlashlight))
return 0.0;
double rawFlashlight = Attributes.FlashlightRating;
double rawFlashlight = Attributes.FlashlightDifficulty;
if (mods.Any(m => m is OsuModTouchDevice))
rawFlashlight = Math.Pow(rawFlashlight, 0.8);
double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0;
// Add an additional bonus for HDFL.
if (mods.Any(h => h is OsuModHidden))
flashlightValue *= 1.3;
@ -242,9 +230,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (effectiveMissCount > 0)
flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
// Combo scaling.
if (Attributes.MaxCombo > 0)
flashlightValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0);
flashlightValue *= getComboScalingFactor();
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
@ -276,6 +262,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return Math.Max(countMiss, (int)Math.Floor(comboBasedMissCount));
}
private double getComboScalingFactor() => Attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0);
private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh;
}

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
}
private double skillMultiplier => 0.15;
private double skillMultiplier => 0.07;
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.
@ -40,26 +40,31 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
double result = 0.0;
OsuDifficultyHitObject lastObj = osuCurrent;
// This is iterating backwards in time from the current object.
for (int i = 0; i < Previous.Count; i++)
{
var osuPrevious = (OsuDifficultyHitObject)Previous[i];
var osuPreviousHitObject = (OsuHitObject)(osuPrevious.BaseObject);
var currentObj = (OsuDifficultyHitObject)Previous[i];
var currentHitObject = (OsuHitObject)(currentObj.BaseObject);
if (!(osuPrevious.BaseObject is Spinner))
if (!(currentObj.BaseObject is Spinner))
{
double jumpDistance = (osuHitObject.StackedPosition - osuPreviousHitObject.EndPosition).Length;
double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.EndPosition).Length;
cumulativeStrainTime += osuPrevious.StrainTime;
cumulativeStrainTime += lastObj.StrainTime;
// We want to nerf objects that can be easily seen within the Flashlight circle radius.
if (i == 0)
smallDistNerf = Math.Min(1.0, jumpDistance / 75.0);
// We also want to nerf stacks so that only the first object of the stack is accounted for.
double stackNerf = Math.Min(1.0, (osuPrevious.LazyJumpDistance / scalingFactor) / 25.0);
double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0);
result += Math.Pow(0.8, i) * stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime;
result += stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime;
}
lastObj = currentObj;
}
return Math.Pow(smallDistNerf * result, 2.0);

View File

@ -9,14 +9,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
public class TaikoDifficultyAttributes : DifficultyAttributes
{
[JsonProperty("stamina_strain")]
public double StaminaStrain { get; set; }
[JsonProperty("stamina_difficulty")]
public double StaminaDifficulty { get; set; }
[JsonProperty("rhythm_strain")]
public double RhythmStrain { get; set; }
[JsonProperty("rhythm_difficulty")]
public double RhythmDifficulty { get; set; }
[JsonProperty("colour_strain")]
public double ColourStrain { get; set; }
[JsonProperty("colour_difficulty")]
public double ColourDifficulty { get; set; }
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }
@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return v;
yield return (ATTRIB_ID_MAX_COMBO, MaxCombo);
yield return (ATTRIB_ID_STRAIN, StarRating);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
}
@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
base.FromDatabaseAttributes(values);
MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO];
StarRating = values[ATTRIB_ID_STRAIN];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
}
}

View File

@ -91,9 +91,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
StarRating = starRating,
Mods = mods,
StaminaStrain = staminaRating,
RhythmStrain = rhythmRating,
ColourStrain = colourRating,
StaminaDifficulty = staminaRating,
RhythmDifficulty = rhythmRating,
ColourDifficulty = colourRating,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
};

View File

@ -0,0 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using osu.Game.Rulesets.Difficulty;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
public class TaikoPerformanceAttributes : PerformanceAttributes
{
[JsonProperty("difficulty")]
public double Difficulty { get; set; }
[JsonProperty("accuracy")]
public double Accuracy { get; set; }
}
}

View File

@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
}
public override double Calculate(Dictionary<string, double> categoryDifficulty = null)
public override PerformanceAttributes Calculate()
{
mods = Score.Mods;
countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great);
@ -35,7 +35,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss);
// Custom multipliers for NoFail and SpunOut.
double multiplier = 1.1; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things
if (mods.Any(m => m is ModNoFail))
@ -44,43 +43,38 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (mods.Any(m => m is ModHidden))
multiplier *= 1.10;
double strainValue = computeStrainValue();
double difficultyValue = computeDifficultyValue();
double accuracyValue = computeAccuracyValue();
double totalValue =
Math.Pow(
Math.Pow(strainValue, 1.1) +
Math.Pow(difficultyValue, 1.1) +
Math.Pow(accuracyValue, 1.1), 1.0 / 1.1
) * multiplier;
if (categoryDifficulty != null)
return new TaikoPerformanceAttributes
{
categoryDifficulty["Strain"] = strainValue;
categoryDifficulty["Accuracy"] = accuracyValue;
}
return totalValue;
Difficulty = difficultyValue,
Accuracy = accuracyValue,
Total = totalValue
};
}
private double computeStrainValue()
private double computeDifficultyValue()
{
double strainValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0;
double difficultyValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0;
// Longer maps are worth more
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
strainValue *= lengthBonus;
difficultyValue *= lengthBonus;
// Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available
strainValue *= Math.Pow(0.985, countMiss);
difficultyValue *= Math.Pow(0.985, countMiss);
if (mods.Any(m => m is ModHidden))
strainValue *= 1.025;
difficultyValue *= 1.025;
if (mods.Any(m => m is ModFlashlight<TaikoHitObject>))
// Apply length bonus again if flashlight is on simply because it becomes a lot harder on longer maps.
strainValue *= 1.05 * lengthBonus;
difficultyValue *= 1.05 * lengthBonus;
// Scale the speed value with accuracy _slightly_
return strainValue * Score.Accuracy;
return difficultyValue * Score.Accuracy;
}
private double computeAccuracyValue()
@ -88,11 +82,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (Attributes.GreatHitWindow <= 0)
return 0;
// Lots of arbitrary values from testing.
// Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
double accValue = Math.Pow(150.0 / Attributes.GreatHitWindow, 1.1) * Math.Pow(Score.Accuracy, 15) * 22.0;
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer
// Bonus for many objects - it's harder to keep good accuracy up for longer
return accValue * Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
}

View File

@ -128,7 +128,7 @@ namespace osu.Game.Tests.Collections.IO
[Test]
public async Task TestSaveAndReload()
{
using (HeadlessGameHost host = new TestRunHeadlessGameHost("TestSaveAndReload", bypassCleanup: true))
using (HeadlessGameHost host = new CleanRunHeadlessGameHost(bypassCleanup: true))
{
try
{
@ -149,7 +149,8 @@ namespace osu.Game.Tests.Collections.IO
}
}
using (HeadlessGameHost host = new TestRunHeadlessGameHost("TestSaveAndReload"))
// Name matches the automatically chosen name from `CleanRunHeadlessGameHost` above, so we end up using the same storage location.
using (HeadlessGameHost host = new TestRunHeadlessGameHost(nameof(TestSaveAndReload)))
{
try
{

View File

@ -61,6 +61,7 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
}
}
}
@ -94,6 +95,7 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
}
}
}
@ -160,6 +162,7 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
}
}
}
@ -168,7 +171,7 @@ namespace osu.Game.Tests.NonVisual
public void TestMigrationBetweenTwoTargets()
{
string customPath = prepareCustomPath();
string customPath2 = prepareCustomPath("-2");
string customPath2 = prepareCustomPath();
using (var host = new CustomTestHeadlessGameHost())
{
@ -185,7 +188,7 @@ namespace osu.Game.Tests.NonVisual
Assert.That(File.Exists(Path.Combine(customPath2, database_filename)));
// some files may have been left behind for whatever reason, but that's not what we're testing here.
customPath = prepareCustomPath();
cleanupPath(customPath);
Assert.DoesNotThrow(() => osu.Migrate(customPath));
Assert.That(File.Exists(Path.Combine(customPath, database_filename)));
@ -193,6 +196,8 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
cleanupPath(customPath2);
}
}
}
@ -214,6 +219,7 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
}
}
}
@ -243,6 +249,7 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
}
}
}
@ -272,6 +279,7 @@ namespace osu.Game.Tests.NonVisual
finally
{
host.Exit();
cleanupPath(customPath);
}
}
}
@ -286,14 +294,18 @@ namespace osu.Game.Tests.NonVisual
return path;
}
private string prepareCustomPath(string suffix = "")
private static string prepareCustomPath() => Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path-{Guid.NewGuid()}");
private static void cleanupPath(string path)
{
string path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path{suffix}");
if (Directory.Exists(path))
Directory.Delete(path, true);
return path;
try
{
if (Directory.Exists(path))
Directory.Delete(path, true);
}
catch
{
}
}
public class CustomTestHeadlessGameHost : CleanRunHeadlessGameHost

View File

@ -68,7 +68,7 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
public void TestPlayingUsersUpdatedOnJoin()
{
AddStep("leave room", () => Client.LeaveRoom());
AddUntilStep("wait for room part", () => Client.Room == null);
AddUntilStep("wait for room part", () => !RoomJoined);
AddStep("create room initially in gameplay", () =>
{

View File

@ -1,9 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework;
using osu.Game.Configuration;
using osu.Game.Input;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Tests.NonVisual
{
@ -11,37 +12,32 @@ namespace osu.Game.Tests.NonVisual
public class SessionStaticsTest
{
private SessionStatics sessionStatics;
private IdleTracker sessionIdleTracker;
[SetUp]
public void SetUp()
[Test]
public void TestSessionStaticsReset()
{
sessionStatics = new SessionStatics();
sessionIdleTracker = new GameIdleTracker(1000);
sessionStatics.SetValue(Static.LoginOverlayDisplayed, true);
sessionStatics.SetValue(Static.MutedAudioNotificationShownOnce, true);
sessionStatics.SetValue(Static.LowBatteryNotificationShownOnce, true);
sessionStatics.SetValue(Static.LastHoverSoundPlaybackTime, (double?)1d);
sessionStatics.SetValue(Static.SeasonalBackgrounds, new APISeasonalBackgrounds { EndDate = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero) });
sessionIdleTracker.IsIdle.BindValueChanged(e =>
{
if (e.NewValue)
sessionStatics.ResetValues();
});
}
Assert.IsFalse(sessionStatics.GetBindable<bool>(Static.LoginOverlayDisplayed).IsDefault);
Assert.IsFalse(sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce).IsDefault);
Assert.IsFalse(sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce).IsDefault);
Assert.IsFalse(sessionStatics.GetBindable<double?>(Static.LastHoverSoundPlaybackTime).IsDefault);
Assert.IsFalse(sessionStatics.GetBindable<APISeasonalBackgrounds>(Static.SeasonalBackgrounds).IsDefault);
[Test]
[Timeout(2000)]
public void TestSessionStaticsReset()
{
sessionIdleTracker.IsIdle.BindValueChanged(e =>
{
Assert.IsTrue(sessionStatics.GetBindable<bool>(Static.LoginOverlayDisplayed).IsDefault);
Assert.IsTrue(sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce).IsDefault);
Assert.IsTrue(sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce).IsDefault);
Assert.IsTrue(sessionStatics.GetBindable<double?>(Static.LastHoverSoundPlaybackTime).IsDefault);
});
sessionStatics.ResetAfterInactivity();
Assert.IsTrue(sessionStatics.GetBindable<bool>(Static.LoginOverlayDisplayed).IsDefault);
Assert.IsTrue(sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce).IsDefault);
Assert.IsTrue(sessionStatics.GetBindable<bool>(Static.LowBatteryNotificationShownOnce).IsDefault);
// some statics should not reset despite inactivity.
Assert.IsFalse(sessionStatics.GetBindable<double?>(Static.LastHoverSoundPlaybackTime).IsDefault);
Assert.IsFalse(sessionStatics.GetBindable<APISeasonalBackgrounds>(Static.SeasonalBackgrounds).IsDefault);
}
}
}

View File

@ -0,0 +1,100 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework;
using osu.Game.Online.Rooms;
namespace osu.Game.Tests.OnlinePlay
{
[TestFixture]
public class PlaylistExtensionsTest
{
[Test]
public void TestEmpty()
{
// mostly an extreme edge case, i.e. during room creation.
var items = Array.Empty<PlaylistItem>();
Assert.Multiple(() =>
{
Assert.That(items.GetHistoricalItems(), Is.Empty);
Assert.That(items.GetCurrentItem(), Is.Null);
Assert.That(items.GetUpcomingItems(), Is.Empty);
});
}
[Test]
public void TestPlaylistItemsInOrder()
{
var items = new[]
{
new PlaylistItem { ID = 1, BeatmapID = 1001, PlaylistOrder = 1 },
new PlaylistItem { ID = 2, BeatmapID = 1002, PlaylistOrder = 2 },
new PlaylistItem { ID = 3, BeatmapID = 1003, PlaylistOrder = 3 },
};
Assert.Multiple(() =>
{
Assert.That(items.GetHistoricalItems(), Is.Empty);
Assert.That(items.GetCurrentItem(), Is.EqualTo(items[0]));
Assert.That(items.GetUpcomingItems(), Is.EquivalentTo(items));
});
}
[Test]
public void TestPlaylistItemsOutOfOrder()
{
var items = new[]
{
new PlaylistItem { ID = 2, BeatmapID = 1002, PlaylistOrder = 2 },
new PlaylistItem { ID = 1, BeatmapID = 1001, PlaylistOrder = 1 },
new PlaylistItem { ID = 3, BeatmapID = 1003, PlaylistOrder = 3 },
};
Assert.Multiple(() =>
{
Assert.That(items.GetHistoricalItems(), Is.Empty);
Assert.That(items.GetCurrentItem(), Is.EqualTo(items[1]));
Assert.That(items.GetUpcomingItems(), Is.EquivalentTo(new[] { items[1], items[0], items[2] }));
});
}
[Test]
public void TestExpiredPlaylistItemsSkipped()
{
var items = new[]
{
new PlaylistItem { ID = 1, BeatmapID = 1001, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 55, 0, TimeSpan.Zero) },
new PlaylistItem { ID = 2, BeatmapID = 1002, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 53, 0, TimeSpan.Zero) },
new PlaylistItem { ID = 3, BeatmapID = 1003, PlaylistOrder = 3 },
};
Assert.Multiple(() =>
{
Assert.That(items.GetHistoricalItems(), Is.EquivalentTo(new[] { items[1], items[0] }));
Assert.That(items.GetCurrentItem(), Is.EqualTo(items[2]));
Assert.That(items.GetUpcomingItems(), Is.EquivalentTo(new[] { items[2] }));
});
}
[Test]
public void TestAllItemsExpired()
{
var items = new[]
{
new PlaylistItem { ID = 1, BeatmapID = 1001, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 55, 0, TimeSpan.Zero) },
new PlaylistItem { ID = 2, BeatmapID = 1002, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 53, 0, TimeSpan.Zero) },
new PlaylistItem { ID = 3, BeatmapID = 1002, Expired = true, PlayedAt = new DateTimeOffset(2021, 12, 21, 7, 57, 0, TimeSpan.Zero) },
};
Assert.Multiple(() =>
{
Assert.That(items.GetHistoricalItems(), Is.EquivalentTo(new[] { items[1], items[0], items[2] }));
// if all items are expired, the last-played item is expected to be returned.
Assert.That(items.GetCurrentItem(), Is.EqualTo(items[2]));
Assert.That(items.GetUpcomingItems(), Is.Empty);
});
}
}
}

View File

@ -96,6 +96,7 @@ namespace osu.Game.Tests.Visual.Beatmaps
var longName = CreateAPIBeatmapSet(Ruleset.Value);
longName.Title = longName.TitleUnicode = "this track has an incredibly and implausibly long title";
longName.Artist = longName.ArtistUnicode = "and this artist! who would have thunk it. it's really such a long name.";
longName.Source = "wow. even the source field has an impossibly long string in it. this really takes the cake, doesn't it?";
longName.HasExplicitContent = true;
longName.TrackId = 444;
@ -251,13 +252,19 @@ namespace osu.Game.Tests.Visual.Beatmaps
[Test]
public void TestNormal()
{
createTestCase(beatmapSetInfo => new BeatmapCard(beatmapSetInfo));
createTestCase(beatmapSetInfo => new BeatmapCardNormal(beatmapSetInfo));
}
[Test]
public void TestExtra()
{
createTestCase(beatmapSetInfo => new BeatmapCardExtra(beatmapSetInfo));
}
[Test]
public void TestHoverState()
{
AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, s => new BeatmapCard(s)));
AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, s => new BeatmapCardNormal(s)));
AddStep("Hover card", () => InputManager.MoveMouseTo(firstCard()));
AddWaitStep("wait for potential state change", 5);
@ -274,10 +281,10 @@ namespace osu.Game.Tests.Visual.Beatmaps
AddWaitStep("wait for potential state change", 5);
AddAssert("card is still expanded", () => firstCard().Expanded.Value);
AddStep("Hover away", () => InputManager.MoveMouseTo(this.ChildrenOfType<BeatmapCard>().Last()));
AddStep("Hover away", () => InputManager.MoveMouseTo(this.ChildrenOfType<BeatmapCardNormal>().Last()));
AddUntilStep("card is not expanded", () => !firstCard().Expanded.Value);
BeatmapCard firstCard() => this.ChildrenOfType<BeatmapCard>().First();
BeatmapCardNormal firstCard() => this.ChildrenOfType<BeatmapCardNormal>().First();
}
}
}

View File

@ -57,7 +57,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private void checkForFirstSamplePlayback()
{
AddUntilStep("storyboard loaded", () => Player.Beatmap.Value.StoryboardLoaded);
AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null);
AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
}

View File

@ -5,7 +5,6 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
@ -20,7 +19,6 @@ using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Screens.Play;
using osu.Game.Tests.Resources;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
@ -86,11 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddWaitStep("wait for transition", 2);
AddStep("create room", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for join", () => Client.RoomJoined);
}
@ -104,24 +98,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
protected void RunGameplay()
{
AddUntilStep("wait for idle", () => Client.LocalUser?.State == MultiplayerUserState.Idle);
clickReadyButton();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("wait for ready", () => Client.LocalUser?.State == MultiplayerUserState.Ready);
clickReadyButton();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded);
AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent());
}
private void clickReadyButton()
{
AddUntilStep("wait for ready button to be enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().ChildrenOfType<Button>().Single().Enabled.Value);
AddStep("click ready button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerReadyButton>().Single());
InputManager.Click(MouseButton.Left);
});
}
}
}

View File

@ -463,11 +463,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
AddStep("click spectate button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerSpectateButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
AddUntilStep("wait for spectating user state", () => client.LocalUser?.State == MultiplayerUserState.Spectating);
@ -501,11 +497,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready);
});
AddStep("click spectate button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerSpectateButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
AddUntilStep("wait for spectating user state", () => client.LocalUser?.State == MultiplayerUserState.Spectating);
@ -807,6 +799,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void pressReadyButton(int? playingUserId = null)
{
// Can't use ClickButtonWhenEnabled<> due to needing to store the state after the button is enabled.
AddUntilStep("wait for ready button to be enabled", () => readyButton.Enabled.Value);
MultiplayerUserState lastState = MultiplayerUserState.Idle;
@ -832,11 +826,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddWaitStep("wait for transition", 2);
AddStep("create room", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for join", () => client.RoomJoined);
}

View File

@ -14,12 +14,10 @@ using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Resources;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
@ -62,22 +60,17 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestCreatedRoom()
{
AddStep("create room", () =>
AddStep("add playlist item", () =>
{
SelectedRoom.Value.Playlist.Add(new PlaylistItem
{
Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
});
// Needs to run after components update with the playlist item.
Schedule(() =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());
InputManager.Click(MouseButton.Left);
});
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for join", () => RoomJoined);
}
@ -110,11 +103,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
});
AddStep("click create button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for room join", () => RoomJoined);
@ -124,21 +113,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready);
});
AddStep("click spectate button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerSpectateButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
AddUntilStep("wait for spectating user state", () => Client.LocalUser?.State == MultiplayerUserState.Spectating);
AddUntilStep("wait for ready button to be enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().ChildrenOfType<ReadyButton>().Single().Enabled.Value);
AddStep("click ready button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerReadyButton>().Single());
InputManager.Click(MouseButton.Left);
});
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("match started", () => Client.Room?.State == MultiplayerRoomState.WaitingForLoad);
}

View File

@ -121,7 +121,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("finish current item", () => Client.FinishCurrentItem());
AddStep("leave room", () => RoomManager.PartRoom());
AddUntilStep("wait for room part", () => Client.Room == null);
AddUntilStep("wait for room part", () => !RoomJoined);
AddUntilStep("item 0 not in lists", () => !inHistoryList(0) && !inQueueList(0));
AddUntilStep("item 1 not in lists", () => !inHistoryList(0) && !inQueueList(0));
@ -132,7 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public void TestJoinRoomWithMixedItemsAddedInCorrectLists()
{
AddStep("leave room", () => RoomManager.PartRoom());
AddUntilStep("wait for room part", () => Client.Room == null);
AddUntilStep("wait for room part", () => !RoomJoined);
AddStep("join room with items", () =>
{

View File

@ -6,14 +6,12 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
@ -22,14 +20,11 @@ using osu.Game.Screens.OnlinePlay;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiplayerQueueList : MultiplayerTestScene
{
private readonly Bindable<PlaylistItem> selectedItem = new Bindable<PlaylistItem>();
[Cached(typeof(UserLookupCache))]
private readonly TestUserLookupCache userLookupCache = new TestUserLookupCache();
@ -52,14 +47,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("create playlist", () =>
{
selectedItem.Value = null;
Child = playlist = new MultiplayerQueueList
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(500, 300),
SelectedItem = { BindTarget = selectedItem },
Items = { BindTarget = Client.APIRoom!.Playlist }
};
});
@ -113,12 +105,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
addPlaylistItem(() => API.LocalUser.Value.OnlineID);
AddStep("select item 0", () => selectedItem.Value = playlist.ChildrenOfType<RearrangeableListItem<PlaylistItem>>().ElementAt(0).Model);
assertDeleteButtonVisibility(0, false);
assertDeleteButtonVisibility(1, true);
AddStep("select item 1", () => selectedItem.Value = playlist.ChildrenOfType<RearrangeableListItem<PlaylistItem>>().ElementAt(1).Model);
assertDeleteButtonVisibility(0, true);
AddStep("finish current item", () => Client.FinishCurrentItem());
AddUntilStep("wait for next item to be selected", () => Client.Room?.Settings.PlaylistItemId == 2);
AddUntilStep("wait for two items in playlist", () => playlist.ChildrenOfType<DrawableRoomPlaylistItem>().Count() == 2);
assertDeleteButtonVisibility(0, false);
assertDeleteButtonVisibility(1, false);
}
@ -142,23 +136,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("item arrived in playlist", () => playlist.ChildrenOfType<RearrangeableListItem<PlaylistItem>>().Any(i => i.Model.ID == itemId));
}
private void deleteItem(int index)
{
OsuRearrangeableListItem<PlaylistItem> item = null;
AddStep($"move mouse to delete button {index}", () =>
{
item = playlist.ChildrenOfType<OsuRearrangeableListItem<PlaylistItem>>().ElementAt(index);
InputManager.MoveMouseTo(item.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(0));
});
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddUntilStep("item removed from playlist", () => !playlist.ChildrenOfType<RearrangeableListItem<PlaylistItem>>().Contains(item));
}
private void assertDeleteButtonVisibility(int index, bool visible)
=> AddUntilStep($"delete button {index} {(visible ? "is" : "is not")} visible",
() => (playlist.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAt(index).Alpha > 0) == visible);
=> AddUntilStep($"delete button {index} {(visible ? "is" : "is not")} visible", () =>
{
var button = playlist.ChildrenOfType<DrawableRoomPlaylistItem.PlaylistRemoveButton>().ElementAtOrDefault(index);
return (button?.Alpha > 0) == visible;
});
}
}

View File

@ -9,7 +9,6 @@ using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Utils;
@ -22,7 +21,6 @@ using osu.Game.Rulesets;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
@ -113,10 +111,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
Client.TransferHost(2);
});
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready);
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle);
}
@ -132,7 +130,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Client.AddUser(new APIUser { Id = 2, Username = "Another user" });
});
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready);
verifyGameplayStartFlow();
@ -147,7 +145,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Client.TransferHost(2);
});
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddStep("make user host", () => Client.TransferHost(Client.Room?.Users[0].UserID ?? 0));
verifyGameplayStartFlow();
@ -162,12 +160,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
Client.AddUser(new APIUser { Id = 2, Username = "Another user" });
});
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready);
AddStep("transfer host", () => Client.TransferHost(Client.Room?.Users[1].UserID ?? 0));
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("user is idle (match not started)", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle);
AddAssert("ready button enabled", () => button.ChildrenOfType<OsuButton>().Single().Enabled.Value);
}
@ -187,7 +185,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
if (!isHost)
AddStep("transfer host", () => Client.TransferHost(2));
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddRepeatStep("change user ready state", () =>
{
@ -202,20 +200,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
}, users);
}
private void addClickButtonStep()
{
AddUntilStep("wait for button to be ready", () => button.ChildrenOfType<Button>().Single().Enabled.Value);
AddStep("click button", () =>
{
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
}
private void verifyGameplayStartFlow()
{
AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready);
addClickButtonStep();
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("user waiting for load", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
AddAssert("ready button disabled", () => !button.ChildrenOfType<OsuButton>().Single().Enabled.Value);

View File

@ -21,7 +21,6 @@ using osu.Game.Rulesets;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
{
@ -121,10 +120,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
[TestCase(MultiplayerUserState.Ready)]
public void TestToggleWhenIdle(MultiplayerUserState initialState)
{
addClickSpectateButtonStep();
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
AddUntilStep("user is spectating", () => Client.Room?.Users[0].State == MultiplayerUserState.Spectating);
addClickSpectateButtonStep();
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
AddUntilStep("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle);
}
@ -138,7 +137,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestReadyButtonDisabledWhenHostAndNoReadyUsers()
{
addClickSpectateButtonStep();
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
assertReadyButtonEnablement(false);
}
@ -148,7 +147,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("add user", () => Client.AddUser(new APIUser { Id = PLAYER_1_ID }));
AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready));
addClickSpectateButtonStep();
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
assertReadyButtonEnablement(true);
}
@ -163,16 +162,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready));
addClickSpectateButtonStep();
ClickButtonWhenEnabled<MultiplayerSpectateButton>();
assertReadyButtonEnablement(false);
}
private void addClickSpectateButtonStep() => AddStep("click spectate button", () =>
{
InputManager.MoveMouseTo(spectateButton);
InputManager.Click(MouseButton.Left);
});
private void assertSpectateButtonEnablement(bool shouldBeEnabled)
=> AddUntilStep($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType<OsuButton>().Single().Enabled.Value == shouldBeEnabled);

View File

@ -157,12 +157,12 @@ namespace osu.Game.Tests.Visual.Navigation
AddUntilStep("wait for fail", () => player.HasFailed);
AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying);
AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().Metadata.PreviewTime);
AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime);
pushEscape();
AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying);
AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().Metadata.PreviewTime);
AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime);
}
[Test]

View File

@ -7,6 +7,7 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
@ -110,23 +111,32 @@ namespace osu.Game.Tests.Visual.Online
public void TestNoBeatmapsPlaceholder()
{
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
placeholderShown();
AddStep("fetch for 1 beatmap", () => fetchFor(CreateAPIBeatmapSet(Ruleset.Value)));
AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray()));
AddUntilStep("wait for loaded", () => this.ChildrenOfType<BeatmapCard>().Count() == 100);
AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
placeholderShown();
// fetch once more to ensure nothing happens in displaying placeholder again when it already is present.
AddStep("fetch for 0 beatmaps again", () => fetchFor());
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
placeholderShown();
void placeholderShown() =>
AddUntilStep("placeholder shown", () =>
{
var notFoundDrawable = overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault();
return notFoundDrawable != null && notFoundDrawable.IsPresent && notFoundDrawable.Parent.DrawHeight > 0;
});
}
[Test]
public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults()
{
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false);
// only Rank Achieved filter
@ -187,6 +197,9 @@ namespace osu.Game.Tests.Visual.Online
public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithResults()
{
AddStep("fetch for 1 beatmap", () => fetchFor(CreateAPIBeatmapSet(Ruleset.Value)));
noPlaceholderShown();
AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false);
// only Rank Achieved filter
@ -217,6 +230,9 @@ namespace osu.Game.Tests.Visual.Online
public void TestUserWithSupporterUsesSupporterOnlyFiltersWithResults()
{
AddStep("fetch for 1 beatmap", () => fetchFor(CreateAPIBeatmapSet(Ruleset.Value)));
noPlaceholderShown();
AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true);
// only Rank Achieved filter
@ -280,9 +296,8 @@ namespace osu.Game.Tests.Visual.Online
private void noPlaceholderShown()
{
AddUntilStep("no placeholder shown", () =>
!overlay.ChildrenOfType<BeatmapListingOverlay.SupporterRequiredDrawable>().Any(d => d.IsPresent)
&& !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
AddUntilStep("\"supporter required\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.SupporterRequiredDrawable>().Any(d => d.IsPresent));
AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
}
}
}

View File

@ -203,8 +203,12 @@ namespace osu.Game.Tests.Visual.Ranking
{
DelayedFetchResultsScreen screen = null;
AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(TestResources.CreateTestScoreInfo(), 3000)));
var tcs = new TaskCompletionSource();
AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(TestResources.CreateTestScoreInfo(), tcs.Task)));
AddUntilStep("wait for loaded", () => screen.IsLoaded);
AddStep("click expanded panel", () =>
{
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
@ -212,6 +216,10 @@ namespace osu.Game.Tests.Visual.Ranking
InputManager.Click(MouseButton.Left);
});
AddAssert("no fetch yet", () => !screen.FetchCompleted);
AddStep("allow fetch", () => tcs.SetResult());
AddUntilStep("wait for fetch", () => screen.FetchCompleted);
AddAssert("expanded panel still on screen", () => this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded).ScreenSpaceDrawQuad.TopLeft.X > 0);
}
@ -295,21 +303,21 @@ namespace osu.Game.Tests.Visual.Ranking
private class DelayedFetchResultsScreen : TestResultsScreen
{
private readonly Task fetchWaitTask;
public bool FetchCompleted { get; private set; }
private readonly double delay;
public DelayedFetchResultsScreen(ScoreInfo score, double delay)
public DelayedFetchResultsScreen(ScoreInfo score, Task fetchWaitTask = null)
: base(score)
{
this.delay = delay;
this.fetchWaitTask = fetchWaitTask ?? Task.CompletedTask;
}
protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback)
{
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMilliseconds(delay));
await fetchWaitTask;
var scores = new List<ScoreInfo>();

View File

@ -3,431 +3,78 @@
#nullable enable
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables.Cards.Buttons;
using osu.Game.Beatmaps.Drawables.Cards.Statistics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapSet;
using osuTK;
using osu.Game.Resources.Localisation.Web;
using DownloadButton = osu.Game.Beatmaps.Drawables.Cards.Buttons.DownloadButton;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class BeatmapCard : OsuClickableContainer
public abstract class BeatmapCard : OsuClickableContainer
{
public const float TRANSITION_DURATION = 400;
public const float CORNER_RADIUS = 10;
public IBindable<bool> Expanded { get; }
private const float width = 408;
private const float height = 100;
private const float icon_area_width = 30;
protected readonly APIBeatmapSet BeatmapSet;
protected readonly Bindable<BeatmapSetFavouriteState> FavouriteState;
private readonly APIBeatmapSet beatmapSet;
private readonly Bindable<BeatmapSetFavouriteState> favouriteState;
protected abstract Drawable IdleContent { get; }
protected abstract Drawable DownloadInProgressContent { get; }
private readonly BeatmapDownloadTracker downloadTracker;
protected readonly BeatmapDownloadTracker DownloadTracker;
private BeatmapCardContent content = null!;
private BeatmapCardThumbnail thumbnail = null!;
private Container rightAreaBackground = null!;
private Container<BeatmapCardIconButton> rightAreaButtons = null!;
private Container mainContent = null!;
private BeatmapCardContentBackground mainContentBackground = null!;
private FillFlowContainer<BeatmapCardStatistic> statisticsContainer = null!;
private FillFlowContainer idleBottomContent = null!;
private BeatmapCardDownloadProgressBar downloadProgressBar = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public BeatmapCard(APIBeatmapSet beatmapSet, bool allowExpansion = true)
protected BeatmapCard(APIBeatmapSet beatmapSet, bool allowExpansion = true)
: base(HoverSampleSet.Submit)
{
Expanded = new BindableBool { Disabled = !allowExpansion };
this.beatmapSet = beatmapSet;
favouriteState = new Bindable<BeatmapSetFavouriteState>(new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount));
downloadTracker = new BeatmapDownloadTracker(beatmapSet);
BeatmapSet = beatmapSet;
FavouriteState = new Bindable<BeatmapSetFavouriteState>(new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount));
DownloadTracker = new BeatmapDownloadTracker(beatmapSet);
}
[BackgroundDependencyLoader(true)]
private void load(BeatmapSetOverlay? beatmapSetOverlay)
{
Width = width;
Height = height;
Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID);
FillFlowContainer leftIconArea;
GridContainer titleContainer;
GridContainer artistContainer;
InternalChild = content = new BeatmapCardContent(height)
{
MainContent = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
downloadTracker,
rightAreaBackground = new Container
{
RelativeSizeAxes = Axes.Y,
Width = icon_area_width + 2 * CORNER_RADIUS,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
// workaround for masking artifacts at the top & bottom of card,
// which become especially visible on downloaded beatmaps (when the icon area has a lime background).
Padding = new MarginPadding { Vertical = 1 },
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Colour4.White
},
},
thumbnail = new BeatmapCardThumbnail(beatmapSet)
{
Name = @"Left (icon) area",
Size = new Vector2(height),
Padding = new MarginPadding { Right = CORNER_RADIUS },
Child = leftIconArea = new FillFlowContainer
{
Margin = new MarginPadding(5),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(1)
}
},
new Container
{
Name = @"Right (button) area",
Width = 30,
RelativeSizeAxes = Axes.Y,
Origin = Anchor.TopRight,
Anchor = Anchor.TopRight,
Padding = new MarginPadding { Vertical = 17.5f },
Child = rightAreaButtons = new Container<BeatmapCardIconButton>
{
RelativeSizeAxes = Axes.Both,
Children = new BeatmapCardIconButton[]
{
new FavouriteButton(beatmapSet)
{
Current = favouriteState,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
},
new DownloadButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
},
new GoToBeatmapButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
}
}
}
},
mainContent = new Container
{
Name = @"Main content",
X = height - CORNER_RADIUS,
Height = height,
CornerRadius = CORNER_RADIUS,
Masking = true,
Children = new Drawable[]
{
mainContentBackground = new BeatmapCardContentBackground(beatmapSet)
{
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Horizontal = 10,
Vertical = 4
},
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
titleContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title),
Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
}
}
},
artistContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = createArtistText(),
Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
},
}
},
new LinkFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold);
}).With(d =>
{
d.AutoSizeAxes = Axes.Both;
d.Margin = new MarginPadding { Top = 2 };
d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
d.AddUserLink(beatmapSet.Author);
}),
}
},
new Container
{
Name = @"Bottom content",
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Padding = new MarginPadding
{
Horizontal = 10,
Vertical = 4
},
Children = new Drawable[]
{
idleBottomContent = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 3),
AlwaysPresent = true,
Children = new Drawable[]
{
statisticsContainer = new FillFlowContainer<BeatmapCardStatistic>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Alpha = 0,
AlwaysPresent = true,
ChildrenEnumerable = createStatistics()
},
new BeatmapCardExtraInfoRow(beatmapSet)
{
Hovered = _ =>
{
content.ExpandAfterDelay();
return false;
},
Unhovered = _ =>
{
// Handles the case where a user has not shown explicit intent to view expanded info.
// ie. quickly moved over the info row area but didn't remain within it.
if (!Expanded.Value)
content.CancelExpand();
}
}
}
},
downloadProgressBar = new BeatmapCardDownloadProgressBar
{
RelativeSizeAxes = Axes.X,
Height = 6,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
State = { BindTarget = downloadTracker.State },
Progress = { BindTarget = downloadTracker.Progress }
}
}
}
}
}
}
},
ExpandedContent = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10, Vertical = 13 },
Child = new BeatmapCardDifficultyList(beatmapSet)
},
Expanded = { BindTarget = Expanded }
};
if (beatmapSet.HasVideo)
leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) });
if (beatmapSet.HasStoryboard)
leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) });
if (beatmapSet.HasExplicitContent)
{
titleContainer.Content[0][1] = new ExplicitContentBeatmapPill
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 5 }
};
}
if (beatmapSet.TrackId != null)
{
artistContainer.Content[0][1] = new FeaturedArtistBeatmapPill
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 5 }
};
}
Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmapSet.OnlineID);
AddInternal(DownloadTracker);
}
protected override void LoadComplete()
{
base.LoadComplete();
downloadTracker.State.BindValueChanged(_ => updateState());
Expanded.BindValueChanged(_ => updateState(), true);
DownloadTracker.State.BindValueChanged(_ => UpdateState());
Expanded.BindValueChanged(_ => UpdateState(), true);
FinishTransforms(true);
}
protected override bool OnHover(HoverEvent e)
{
updateState();
UpdateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
UpdateState();
base.OnHoverLost(e);
}
private LocalisableString createArtistText()
protected virtual void UpdateState()
{
var romanisableArtist = new RomanisableString(beatmapSet.ArtistUnicode, beatmapSet.Artist);
return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist);
}
bool showProgress = DownloadTracker.State.Value == DownloadState.Downloading || DownloadTracker.State.Value == DownloadState.Importing;
private IEnumerable<BeatmapCardStatistic> createStatistics()
{
if (beatmapSet.HypeStatus != null)
yield return new HypesStatistic(beatmapSet.HypeStatus);
// web does not show nominations unless hypes are also present.
// see: https://github.com/ppy/osu-web/blob/8ed7d071fd1d3eaa7e43cf0e4ff55ca2fef9c07c/resources/assets/lib/beatmapset-panel.tsx#L443
if (beatmapSet.HypeStatus != null && beatmapSet.NominationStatus != null)
yield return new NominationsStatistic(beatmapSet.NominationStatus);
yield return new FavouritesStatistic(beatmapSet) { Current = favouriteState };
yield return new PlayCountStatistic(beatmapSet);
var dateStatistic = BeatmapCardDateStatistic.CreateFor(beatmapSet);
if (dateStatistic != null)
yield return dateStatistic;
}
private void updateState()
{
bool showDetails = IsHovered || Expanded.Value;
float targetWidth = width - height;
if (showDetails)
targetWidth = targetWidth - icon_area_width + CORNER_RADIUS;
thumbnail.Dimmed.Value = showDetails;
// Scale value is intentionally chosen to fit in the spacing of listing displays, as to not overlap horizontally with adjacent cards.
// This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left.
content.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint);
mainContent.ResizeWidthTo(targetWidth, TRANSITION_DURATION, Easing.OutQuint);
mainContentBackground.Dimmed.Value = showDetails;
statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
rightAreaBackground.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, TRANSITION_DURATION, Easing.OutQuint);
rightAreaButtons.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
foreach (var button in rightAreaButtons)
{
button.IdleColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Light1 : colourProvider.Background3;
button.HoverColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Content1 : colourProvider.Foreground1;
}
bool showProgress = downloadTracker.State.Value == DownloadState.Downloading || downloadTracker.State.Value == DownloadState.Importing;
idleBottomContent.FadeTo(showProgress ? 0 : 1, TRANSITION_DURATION, Easing.OutQuint);
downloadProgressBar.FadeTo(showProgress ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
IdleContent.FadeTo(showProgress ? 0 : 1, TRANSITION_DURATION, Easing.OutQuint);
DownloadInProgressContent.FadeTo(showProgress ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
}
}
}

View File

@ -139,6 +139,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards
private void updateState()
{
// Scale value is intentionally chosen to fit in the spacing of listing displays, as to not overlap horizontally with adjacent cards.
// This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left.
this.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint);
background.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
dropdownContent.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
borderContainer.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);

View File

@ -0,0 +1,317 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables.Cards.Statistics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapSet;
using osuTK;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class BeatmapCardExtra : BeatmapCard
{
protected override Drawable IdleContent => idleBottomContent;
protected override Drawable DownloadInProgressContent => downloadProgressBar;
private const float width = 475;
private const float height = 140;
[Cached]
private readonly BeatmapCardContent content;
private BeatmapCardThumbnail thumbnail = null!;
private CollapsibleButtonContainer buttonContainer = null!;
private GridContainer statisticsContainer = null!;
private FillFlowContainer idleBottomContent = null!;
private BeatmapCardDownloadProgressBar downloadProgressBar = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public BeatmapCardExtra(APIBeatmapSet beatmapSet, bool allowExpansion = true)
: base(beatmapSet, allowExpansion)
{
content = new BeatmapCardContent(height);
}
[BackgroundDependencyLoader(true)]
private void load(BeatmapSetOverlay? beatmapSetOverlay)
{
Width = width;
Height = height;
FillFlowContainer leftIconArea = null!;
GridContainer titleContainer = null!;
GridContainer artistContainer = null!;
Child = content.With(c =>
{
c.MainContent = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
thumbnail = new BeatmapCardThumbnail(BeatmapSet)
{
Name = @"Left (icon) area",
Size = new Vector2(height),
Padding = new MarginPadding { Right = CORNER_RADIUS },
Child = leftIconArea = new FillFlowContainer
{
Margin = new MarginPadding(5),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(1)
}
},
buttonContainer = new CollapsibleButtonContainer(BeatmapSet)
{
X = height - CORNER_RADIUS,
Width = width - height + CORNER_RADIUS,
FavouriteState = { BindTarget = FavouriteState },
ButtonsCollapsedWidth = CORNER_RADIUS,
ButtonsExpandedWidth = 30,
ButtonsPadding = new MarginPadding { Vertical = 35 },
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
titleContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title),
Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
}
}
},
artistContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = createArtistText(),
Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
},
}
},
new OsuSpriteText
{
RelativeSizeAxes = Axes.X,
Truncate = true,
Text = BeatmapSet.Source,
Shadow = false,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold),
Colour = colourProvider.Content2
},
}
},
new Container
{
Name = @"Bottom content",
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Children = new Drawable[]
{
idleBottomContent = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 3),
AlwaysPresent = true,
Children = new Drawable[]
{
new LinkFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold);
}).With(d =>
{
d.AutoSizeAxes = Axes.Both;
d.Margin = new MarginPadding { Top = 2 };
d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
d.AddUserLink(BeatmapSet.Author);
}),
statisticsContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize)
},
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension()
},
Content = new[]
{
new Drawable[3],
new Drawable[3]
}
},
new BeatmapCardExtraInfoRow(BeatmapSet)
}
},
downloadProgressBar = new BeatmapCardDownloadProgressBar
{
RelativeSizeAxes = Axes.X,
Height = 6,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
State = { BindTarget = DownloadTracker.State },
Progress = { BindTarget = DownloadTracker.Progress }
}
}
}
}
}
}
};
c.ExpandedContent = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10, Vertical = 13 },
Child = new BeatmapCardDifficultyList(BeatmapSet)
};
c.Expanded.BindTarget = Expanded;
});
if (BeatmapSet.HasVideo)
leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) });
if (BeatmapSet.HasStoryboard)
leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) });
if (BeatmapSet.HasExplicitContent)
{
titleContainer.Content[0][1] = new ExplicitContentBeatmapPill
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 5 }
};
}
if (BeatmapSet.TrackId != null)
{
artistContainer.Content[0][1] = new FeaturedArtistBeatmapPill
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 5 }
};
}
createStatistics();
Action = () => beatmapSetOverlay?.FetchAndShowBeatmapSet(BeatmapSet.OnlineID);
}
private LocalisableString createArtistText()
{
var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist);
return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist);
}
private void createStatistics()
{
BeatmapCardStatistic withMargin(BeatmapCardStatistic original)
{
original.Margin = new MarginPadding { Right = 10 };
return original;
}
statisticsContainer.Content[0][0] = withMargin(new FavouritesStatistic(BeatmapSet)
{
Current = FavouriteState,
});
statisticsContainer.Content[1][0] = withMargin(new PlayCountStatistic(BeatmapSet));
var hypesStatistic = HypesStatistic.CreateFor(BeatmapSet);
if (hypesStatistic != null)
statisticsContainer.Content[0][1] = withMargin(hypesStatistic);
var nominationsStatistic = NominationsStatistic.CreateFor(BeatmapSet);
if (nominationsStatistic != null)
statisticsContainer.Content[1][1] = withMargin(nominationsStatistic);
var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet);
if (dateStatistic != null)
statisticsContainer.Content[0][2] = withMargin(dateStatistic);
}
protected override void UpdateState()
{
base.UpdateState();
bool showDetails = IsHovered || Expanded.Value;
buttonContainer.ShowDetails.Value = showDetails;
thumbnail.Dimmed.Value = showDetails;
}
}
}

View File

@ -1,21 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class BeatmapCardExtraInfoRow : HoverHandlingContainer
public class BeatmapCardExtraInfoRow : CompositeDrawable
{
[Resolved(CanBeNull = true)]
private BeatmapCardContent? content { get; set; }
public BeatmapCardExtraInfoRow(APIBeatmapSet beatmapSet)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Child = new FillFlowContainer
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
@ -39,5 +46,19 @@ namespace osu.Game.Beatmaps.Drawables.Cards
}
};
}
protected override bool OnHover(HoverEvent e)
{
content?.ExpandAfterDelay();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
if (content?.Expanded.Value == false)
content.CancelExpand();
base.OnHoverLost(e);
}
}
}

View File

@ -0,0 +1,286 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables.Cards.Statistics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapSet;
using osuTK;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class BeatmapCardNormal : BeatmapCard
{
protected override Drawable IdleContent => idleBottomContent;
protected override Drawable DownloadInProgressContent => downloadProgressBar;
private const float width = 408;
private const float height = 100;
[Cached]
private readonly BeatmapCardContent content;
private BeatmapCardThumbnail thumbnail = null!;
private CollapsibleButtonContainer buttonContainer = null!;
private FillFlowContainer<BeatmapCardStatistic> statisticsContainer = null!;
private FillFlowContainer idleBottomContent = null!;
private BeatmapCardDownloadProgressBar downloadProgressBar = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public BeatmapCardNormal(APIBeatmapSet beatmapSet, bool allowExpansion = true)
: base(beatmapSet, allowExpansion)
{
content = new BeatmapCardContent(height);
}
[BackgroundDependencyLoader]
private void load()
{
Width = width;
Height = height;
FillFlowContainer leftIconArea = null!;
GridContainer titleContainer = null!;
GridContainer artistContainer = null!;
Child = content.With(c =>
{
c.MainContent = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
thumbnail = new BeatmapCardThumbnail(BeatmapSet)
{
Name = @"Left (icon) area",
Size = new Vector2(height),
Padding = new MarginPadding { Right = CORNER_RADIUS },
Child = leftIconArea = new FillFlowContainer
{
Margin = new MarginPadding(5),
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(1)
}
},
buttonContainer = new CollapsibleButtonContainer(BeatmapSet)
{
X = height - CORNER_RADIUS,
Width = width - height + CORNER_RADIUS,
FavouriteState = { BindTarget = FavouriteState },
ButtonsCollapsedWidth = CORNER_RADIUS,
ButtonsExpandedWidth = 30,
ButtonsPadding = new MarginPadding { Vertical = 17.5f },
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
titleContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title),
Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
}
}
},
artistContainer = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.AutoSize)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Text = createArtistText(),
Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold),
RelativeSizeAxes = Axes.X,
Truncate = true
},
Empty()
},
}
},
new LinkFlowContainer(s =>
{
s.Shadow = false;
s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold);
}).With(d =>
{
d.AutoSizeAxes = Axes.Both;
d.Margin = new MarginPadding { Top = 2 };
d.AddText("mapped by ", t => t.Colour = colourProvider.Content2);
d.AddUserLink(BeatmapSet.Author);
}),
}
},
new Container
{
Name = @"Bottom content",
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Children = new Drawable[]
{
idleBottomContent = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 3),
AlwaysPresent = true,
Children = new Drawable[]
{
statisticsContainer = new FillFlowContainer<BeatmapCardStatistic>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Alpha = 0,
AlwaysPresent = true,
ChildrenEnumerable = createStatistics()
},
new BeatmapCardExtraInfoRow(BeatmapSet)
}
},
downloadProgressBar = new BeatmapCardDownloadProgressBar
{
RelativeSizeAxes = Axes.X,
Height = 6,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
State = { BindTarget = DownloadTracker.State },
Progress = { BindTarget = DownloadTracker.Progress }
}
}
}
}
}
}
};
c.ExpandedContent = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10, Vertical = 13 },
Child = new BeatmapCardDifficultyList(BeatmapSet)
};
c.Expanded.BindTarget = Expanded;
});
if (BeatmapSet.HasVideo)
leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) });
if (BeatmapSet.HasStoryboard)
leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) });
if (BeatmapSet.HasExplicitContent)
{
titleContainer.Content[0][1] = new ExplicitContentBeatmapPill
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 5 }
};
}
if (BeatmapSet.TrackId != null)
{
artistContainer.Content[0][1] = new FeaturedArtistBeatmapPill
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding { Left = 5 }
};
}
}
private LocalisableString createArtistText()
{
var romanisableArtist = new RomanisableString(BeatmapSet.ArtistUnicode, BeatmapSet.Artist);
return BeatmapsetsStrings.ShowDetailsByArtist(romanisableArtist);
}
private IEnumerable<BeatmapCardStatistic> createStatistics()
{
var hypesStatistic = HypesStatistic.CreateFor(BeatmapSet);
if (hypesStatistic != null)
yield return hypesStatistic;
var nominationsStatistic = NominationsStatistic.CreateFor(BeatmapSet);
if (nominationsStatistic != null)
yield return nominationsStatistic;
yield return new FavouritesStatistic(BeatmapSet) { Current = FavouriteState };
yield return new PlayCountStatistic(BeatmapSet);
var dateStatistic = BeatmapCardDateStatistic.CreateFor(BeatmapSet);
if (dateStatistic != null)
yield return dateStatistic;
}
protected override void UpdateState()
{
base.UpdateState();
bool showDetails = IsHovered || Expanded.Value;
buttonContainer.ShowDetails.Value = showDetails;
thumbnail.Dimmed.Value = showDetails;
statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
}
}
}

View File

@ -0,0 +1,184 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Beatmaps.Drawables.Cards.Buttons;
using osu.Game.Graphics;
using osu.Game.Online;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public class CollapsibleButtonContainer : Container
{
public Bindable<bool> ShowDetails = new Bindable<bool>();
public Bindable<BeatmapSetFavouriteState> FavouriteState = new Bindable<BeatmapSetFavouriteState>();
private readonly BeatmapDownloadTracker downloadTracker;
private float buttonsExpandedWidth;
public float ButtonsExpandedWidth
{
get => buttonsExpandedWidth;
set
{
buttonsExpandedWidth = value;
buttonArea.Width = value;
if (IsLoaded)
updateState();
}
}
private float buttonsCollapsedWidth;
public float ButtonsCollapsedWidth
{
get => buttonsCollapsedWidth;
set
{
buttonsCollapsedWidth = value;
if (IsLoaded)
updateState();
}
}
public MarginPadding ButtonsPadding
{
get => buttons.Padding;
set => buttons.Padding = value;
}
protected override Container<Drawable> Content => mainContent;
private readonly Container background;
private readonly Container buttonArea;
private readonly Container<BeatmapCardIconButton> buttons;
private readonly Container mainArea;
private readonly Container mainContent;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public CollapsibleButtonContainer(APIBeatmapSet beatmapSet)
{
downloadTracker = new BeatmapDownloadTracker(beatmapSet);
RelativeSizeAxes = Axes.Y;
Masking = true;
CornerRadius = BeatmapCard.CORNER_RADIUS;
InternalChildren = new Drawable[]
{
downloadTracker,
background = new Container
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
// workaround for masking artifacts at the top & bottom of card,
// which become especially visible on downloaded beatmaps (when the icon area has a lime background).
Padding = new MarginPadding { Vertical = 1 },
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Colour4.White
},
},
buttonArea = new Container
{
Name = @"Right (button) area",
RelativeSizeAxes = Axes.Y,
Origin = Anchor.TopRight,
Anchor = Anchor.TopRight,
Child = buttons = new Container<BeatmapCardIconButton>
{
RelativeSizeAxes = Axes.Both,
Children = new BeatmapCardIconButton[]
{
new FavouriteButton(beatmapSet)
{
Current = FavouriteState,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
},
new DownloadButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
},
new GoToBeatmapButton(beatmapSet)
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
State = { BindTarget = downloadTracker.State }
}
}
}
},
mainArea = new Container
{
Name = @"Main content",
RelativeSizeAxes = Axes.Y,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Masking = true,
Children = new Drawable[]
{
new BeatmapCardContentBackground(beatmapSet)
{
RelativeSizeAxes = Axes.Both,
Dimmed = { BindTarget = ShowDetails }
},
mainContent = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Horizontal = 10,
Vertical = 4
},
}
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
downloadTracker.State.BindValueChanged(_ => updateState());
ShowDetails.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
}
private void updateState()
{
float targetWidth = Width - (ShowDetails.Value ? ButtonsExpandedWidth : ButtonsCollapsedWidth);
mainArea.ResizeWidthTo(targetWidth, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
background.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
buttons.FadeTo(ShowDetails.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
foreach (var button in buttons)
{
button.IdleColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Light1 : colourProvider.Background3;
button.HoverColour = downloadTracker.State.Value != DownloadState.LocallyAvailable ? colourProvider.Content1 : colourProvider.Foreground1;
}
}
}
}

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
@ -12,11 +14,14 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics
/// </summary>
public class HypesStatistic : BeatmapCardStatistic
{
public HypesStatistic(BeatmapSetHypeStatus hypeStatus)
private HypesStatistic(BeatmapSetHypeStatus hypeStatus)
{
Icon = FontAwesome.Solid.Bullhorn;
Text = hypeStatus.Current.ToLocalisableString();
TooltipText = BeatmapsStrings.HypeRequiredText(hypeStatus.Current.ToLocalisableString(), hypeStatus.Required.ToLocalisableString());
}
public static HypesStatistic? CreateFor(IBeatmapSetOnlineInfo beatmapSetOnlineInfo)
=> beatmapSetOnlineInfo.HypeStatus == null ? null : new HypesStatistic(beatmapSetOnlineInfo.HypeStatus);
}
}

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
@ -12,11 +14,16 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics
/// </summary>
public class NominationsStatistic : BeatmapCardStatistic
{
public NominationsStatistic(BeatmapSetNominationStatus nominationStatus)
private NominationsStatistic(BeatmapSetNominationStatus nominationStatus)
{
Icon = FontAwesome.Solid.ThumbsUp;
Text = nominationStatus.Current.ToLocalisableString();
TooltipText = BeatmapsStrings.NominationsRequiredText(nominationStatus.Current.ToLocalisableString(), nominationStatus.Required.ToLocalisableString());
}
public static NominationsStatistic? CreateFor(IBeatmapSetOnlineInfo beatmapSetOnlineInfo)
// web does not show nominations unless hypes are also present.
// see: https://github.com/ppy/osu-web/blob/8ed7d071fd1d3eaa7e43cf0e4ff55ca2fef9c07c/resources/assets/lib/beatmapset-panel.tsx#L443
=> beatmapSetOnlineInfo.HypeStatus == null || beatmapSetOnlineInfo.NominationStatus == null ? null : new NominationsStatistic(beatmapSetOnlineInfo.NominationStatus);
}
}

View File

@ -15,39 +15,18 @@ using osu.Game.Storyboards;
namespace osu.Game.Beatmaps
{
/// <summary>
/// Provides access to the multiple resources offered by a beatmap model (textures, skins, playable beatmaps etc.)
/// </summary>
public interface IWorkingBeatmap
{
IBeatmapInfo BeatmapInfo { get; }
IBeatmapSetInfo BeatmapSetInfo { get; }
IBeatmapMetadataInfo Metadata { get; }
/// <summary>
/// Whether the Beatmap has finished loading.
///</summary>
public bool BeatmapLoaded { get; }
/// <summary>
/// Whether the Background has finished loading.
///</summary>
public bool BackgroundLoaded { get; }
/// <summary>
/// Whether the Waveform has finished loading.
///</summary>
public bool WaveformLoaded { get; }
/// <summary>
/// Whether the Storyboard has finished loading.
///</summary>
public bool StoryboardLoaded { get; }
/// <summary>
/// Whether the Skin has finished loading.
///</summary>
public bool SkinLoaded { get; }
/// <summary>
/// Whether the Track has finished loading.
///</summary>

View File

@ -28,24 +28,126 @@ namespace osu.Game.Beatmaps
{
public readonly BeatmapInfo BeatmapInfo;
public readonly BeatmapSetInfo BeatmapSetInfo;
public readonly BeatmapMetadata Metadata;
protected AudioManager AudioManager { get; }
// TODO: remove once the fallback lookup is not required (and access via `working.BeatmapInfo.Metadata` directly).
public BeatmapMetadata Metadata => BeatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata();
public Waveform Waveform => waveform.Value;
public Storyboard Storyboard => storyboard.Value;
public Texture Background => GetBackground(); // Texture uses ref counting, so we want to return a new instance every usage.
public ISkin Skin => skin.Value;
private AudioManager audioManager { get; }
private CancellationTokenSource loadCancellationSource = new CancellationTokenSource();
private readonly object beatmapFetchLock = new object();
private readonly Lazy<Waveform> waveform;
private readonly Lazy<Storyboard> storyboard;
private readonly Lazy<ISkin> skin;
private Track track; // track is not Lazy as we allow transferring and loading multiple times.
protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager)
{
AudioManager = audioManager;
this.audioManager = audioManager;
BeatmapInfo = beatmapInfo;
BeatmapSetInfo = beatmapInfo.BeatmapSet;
Metadata = beatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata();
background = new RecyclableLazy<Texture>(GetBackground, BackgroundStillValid);
waveform = new RecyclableLazy<Waveform>(GetWaveform);
storyboard = new RecyclableLazy<Storyboard>(GetStoryboard);
skin = new RecyclableLazy<ISkin>(GetSkin);
waveform = new Lazy<Waveform>(GetWaveform);
storyboard = new Lazy<Storyboard>(GetStoryboard);
skin = new Lazy<ISkin>(GetSkin);
}
protected virtual Track GetVirtualTrack(double emptyLength = 0)
#region Resource getters
protected virtual Waveform GetWaveform() => new Waveform(null);
protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo };
protected abstract IBeatmap GetBeatmap();
protected abstract Texture GetBackground();
protected abstract Track GetBeatmapTrack();
/// <summary>
/// Creates a new skin instance for this beatmap.
/// </summary>
/// <remarks>
/// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin
/// (e.g. for editing purposes, to avoid state pollution).
/// For standard reading purposes, <see cref="Skin"/> should always be used directly.
/// </remarks>
protected internal abstract ISkin GetSkin();
#endregion
#region Async load control
public void BeginAsyncLoad() => loadBeatmapAsync();
public void CancelAsyncLoad()
{
lock (beatmapFetchLock)
{
loadCancellationSource?.Cancel();
loadCancellationSource = new CancellationTokenSource();
if (beatmapLoadTask?.IsCompleted != true)
beatmapLoadTask = null;
}
}
#endregion
#region Track
public virtual bool TrackLoaded => track != null;
public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000);
public void PrepareTrackForPreviewLooping()
{
Track.Looping = true;
Track.RestartPoint = Metadata.PreviewTime;
if (Track.RestartPoint == -1)
{
if (!Track.IsLoaded)
{
// force length to be populated (https://github.com/ppy/osu-framework/issues/4202)
Track.Seek(Track.CurrentTime);
}
Track.RestartPoint = 0.4f * Track.Length;
}
}
/// <summary>
/// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap
/// across difficulties in the same beatmap set.
/// </summary>
/// <param name="track">The track to transfer.</param>
public void TransferTrack([NotNull] Track track) => this.track = track ?? throw new ArgumentNullException(nameof(track));
/// <summary>
/// Get the loaded audio track instance. <see cref="LoadTrack"/> must have first been called.
/// This generally happens via MusicController when changing the global beatmap.
/// </summary>
public Track Track
{
get
{
if (!TrackLoaded)
throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}.");
return track;
}
}
protected Track GetVirtualTrack(double emptyLength = 0)
{
const double excess_length = 1000;
@ -68,18 +170,67 @@ namespace osu.Game.Beatmaps
break;
}
return AudioManager.Tracks.GetVirtual(length);
return audioManager.Tracks.GetVirtual(length);
}
/// <summary>
/// Creates a <see cref="IBeatmapConverter"/> to convert a <see cref="IBeatmap"/> for a specified <see cref="Ruleset"/>.
/// </summary>
/// <param name="beatmap">The <see cref="IBeatmap"/> to be converted.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> for which <paramref name="beatmap"/> should be converted.</param>
/// <returns>The applicable <see cref="IBeatmapConverter"/>.</returns>
protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap);
#endregion
public IBeatmap GetPlayableBeatmap([NotNull] IRulesetInfo ruleset, IReadOnlyList<Mod> mods = null)
#region Beatmap
public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false;
public IBeatmap Beatmap
{
get
{
try
{
return loadBeatmapAsync().Result;
}
catch (AggregateException ae)
{
// This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load
if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException)
return null;
Logger.Error(ae, "Beatmap failed to load");
return null;
}
catch (Exception e)
{
Logger.Error(e, "Beatmap failed to load");
return null;
}
}
}
private Task<IBeatmap> beatmapLoadTask;
private Task<IBeatmap> loadBeatmapAsync()
{
lock (beatmapFetchLock)
{
return beatmapLoadTask ??= Task.Factory.StartNew(() =>
{
// Todo: Handle cancellation during beatmap parsing
var b = GetBeatmap() ?? new Beatmap();
// The original beatmap version needs to be preserved as the database doesn't contain it
BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion;
// Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc)
b.BeatmapInfo = BeatmapInfo;
return b;
}, loadCancellationSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
}
#endregion
#region Playable beatmap
public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods = null)
{
try
{
@ -95,7 +246,7 @@ namespace osu.Game.Beatmaps
}
}
public virtual IBeatmap GetPlayableBeatmap([NotNull] IRulesetInfo ruleset, [NotNull] IReadOnlyList<Mod> mods, CancellationToken token)
public virtual IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods, CancellationToken token)
{
var rulesetInstance = ruleset.CreateInstance();
@ -169,207 +320,21 @@ namespace osu.Game.Beatmaps
return converted;
}
private CancellationTokenSource loadCancellation = new CancellationTokenSource();
/// <summary>
/// Creates a <see cref="IBeatmapConverter"/> to convert a <see cref="IBeatmap"/> for a specified <see cref="Ruleset"/>.
/// </summary>
/// <param name="beatmap">The <see cref="IBeatmap"/> to be converted.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> for which <paramref name="beatmap"/> should be converted.</param>
/// <returns>The applicable <see cref="IBeatmapConverter"/>.</returns>
protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap);
public void BeginAsyncLoad() => loadBeatmapAsync();
public void CancelAsyncLoad()
{
lock (beatmapFetchLock)
{
loadCancellation?.Cancel();
loadCancellation = new CancellationTokenSource();
if (beatmapLoadTask?.IsCompleted != true)
beatmapLoadTask = null;
}
}
private readonly object beatmapFetchLock = new object();
private Task<IBeatmap> loadBeatmapAsync()
{
lock (beatmapFetchLock)
{
return beatmapLoadTask ??= Task.Factory.StartNew(() =>
{
// Todo: Handle cancellation during beatmap parsing
var b = GetBeatmap() ?? new Beatmap();
// The original beatmap version needs to be preserved as the database doesn't contain it
BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion;
// Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc)
b.BeatmapInfo = BeatmapInfo;
return b;
}, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
}
#endregion
public override string ToString() => BeatmapInfo.ToString();
public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false;
IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo;
IBeatmapMetadataInfo IWorkingBeatmap.Metadata => Metadata;
IBeatmapSetInfo IWorkingBeatmap.BeatmapSetInfo => BeatmapSetInfo;
public IBeatmap Beatmap
{
get
{
try
{
return loadBeatmapAsync().Result;
}
catch (AggregateException ae)
{
// This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load
if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException)
return null;
Logger.Error(ae, "Beatmap failed to load");
return null;
}
catch (Exception e)
{
Logger.Error(e, "Beatmap failed to load");
return null;
}
}
}
protected abstract IBeatmap GetBeatmap();
private Task<IBeatmap> beatmapLoadTask;
public bool BackgroundLoaded => background.IsResultAvailable;
public Texture Background => background.Value;
protected virtual bool BackgroundStillValid(Texture b) => b == null || b.Available;
protected abstract Texture GetBackground();
private readonly RecyclableLazy<Texture> background;
private Track loadedTrack;
[NotNull]
public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000);
public void PrepareTrackForPreviewLooping()
{
Track.Looping = true;
Track.RestartPoint = Metadata.PreviewTime;
if (Track.RestartPoint == -1)
{
if (!Track.IsLoaded)
{
// force length to be populated (https://github.com/ppy/osu-framework/issues/4202)
Track.Seek(Track.CurrentTime);
}
Track.RestartPoint = 0.4f * Track.Length;
}
}
/// <summary>
/// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap
/// across difficulties in the same beatmap set.
/// </summary>
/// <param name="track">The track to transfer.</param>
public void TransferTrack([NotNull] Track track) => loadedTrack = track ?? throw new ArgumentNullException(nameof(track));
/// <summary>
/// Whether this beatmap's track has been loaded via <see cref="LoadTrack"/>.
/// </summary>
public virtual bool TrackLoaded => loadedTrack != null;
/// <summary>
/// Get the loaded audio track instance. <see cref="LoadTrack"/> must have first been called.
/// This generally happens via MusicController when changing the global beatmap.
/// </summary>
public Track Track
{
get
{
if (!TrackLoaded)
throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}.");
return loadedTrack;
}
}
protected abstract Track GetBeatmapTrack();
public bool WaveformLoaded => waveform.IsResultAvailable;
public Waveform Waveform => waveform.Value;
protected virtual Waveform GetWaveform() => new Waveform(null);
private readonly RecyclableLazy<Waveform> waveform;
public bool StoryboardLoaded => storyboard.IsResultAvailable;
public Storyboard Storyboard => storyboard.Value;
protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo };
private readonly RecyclableLazy<Storyboard> storyboard;
public bool SkinLoaded => skin.IsResultAvailable;
public ISkin Skin => skin.Value;
/// <summary>
/// Creates a new skin instance for this beatmap.
/// </summary>
/// <remarks>
/// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin
/// (e.g. for editing purposes, to avoid state pollution).
/// For standard reading purposes, <see cref="Skin"/> should always be used directly.
/// </remarks>
protected internal abstract ISkin GetSkin();
private readonly RecyclableLazy<ISkin> skin;
public abstract Stream GetStream(string storagePath);
public class RecyclableLazy<T>
{
private Lazy<T> lazy;
private readonly Func<T> valueFactory;
private readonly Func<T, bool> stillValidFunction;
private readonly object fetchLock = new object();
public RecyclableLazy(Func<T> valueFactory, Func<T, bool> stillValidFunction = null)
{
this.valueFactory = valueFactory;
this.stillValidFunction = stillValidFunction;
recreate();
}
public void Recycle()
{
if (!IsResultAvailable) return;
(lazy.Value as IDisposable)?.Dispose();
recreate();
}
public bool IsResultAvailable => stillValid;
public T Value
{
get
{
lock (fetchLock)
{
if (!stillValid)
recreate();
return lazy.Value;
}
}
}
private bool stillValid => lazy.IsValueCreated && (stillValidFunction?.Invoke(lazy.Value) ?? true);
private void recreate() => lazy = new Lazy<T>(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication);
}
IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo;
private class BeatmapLoadTimeoutException : TimeoutException
{

View File

@ -145,8 +145,6 @@ namespace osu.Game.Beatmaps
}
}
protected override bool BackgroundStillValid(Texture b) => false; // bypass lazy logic. we want to return a new background each time for refcounting purposes.
protected override Texture GetBackground()
{
if (string.IsNullOrEmpty(Metadata?.BackgroundFile))

View File

@ -102,7 +102,6 @@ namespace osu.Game.Collections
RelativeSizeAxes = Axes.Both,
Size = Vector2.One,
CornerRadius = item_height / 2,
Current = collection.Name,
PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection"
},
}
@ -114,6 +113,9 @@ namespace osu.Game.Collections
{
base.LoadComplete();
// Bind late, as the collection name may change externally while still loading.
textBox.Current = collection.Name;
collectionName.BindValueChanged(_ => createNewCollection(), true);
IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true);
}

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
@ -13,18 +12,27 @@ namespace osu.Game.Configuration
/// </summary>
public class SessionStatics : InMemoryConfigManager<Static>
{
protected override void InitialiseDefaults() => ResetValues();
public void ResetValues()
protected override void InitialiseDefaults()
{
ensureDefault(SetDefault(Static.LoginOverlayDisplayed, false));
ensureDefault(SetDefault(Static.MutedAudioNotificationShownOnce, false));
ensureDefault(SetDefault(Static.LowBatteryNotificationShownOnce, false));
ensureDefault(SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null));
ensureDefault(SetDefault<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null));
SetDefault(Static.LoginOverlayDisplayed, false);
SetDefault(Static.MutedAudioNotificationShownOnce, false);
SetDefault(Static.LowBatteryNotificationShownOnce, false);
SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null);
SetDefault<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null);
}
private void ensureDefault<T>(Bindable<T> bindable) => bindable.SetDefault();
/// <summary>
/// Revert statics to their defaults after being idle for appropriate amount of time.
/// </summary>
/// <remarks>
/// This only affects a subset of statics which the user would expect to have reset after a break.
/// </remarks>
public void ResetAfterInactivity()
{
GetBindable<bool>(Static.LoginOverlayDisplayed).SetDefault();
GetBindable<bool>(Static.MutedAudioNotificationShownOnce).SetDefault();
GetBindable<bool>(Static.LowBatteryNotificationShownOnce).SetDefault();
}
}
public enum Static

View File

@ -6,20 +6,13 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Database
{
// This class is based on `UserLookupCache` which is well tested.
// If modifications are to be made here, a base abstract implementation should likely be created and shared between the two.
public class BeatmapLookupCache : MemoryCachingComponent<int, APIBeatmap>
public class BeatmapLookupCache : OnlineLookupCache<int, APIBeatmap, GetBeatmapsRequest>
{
[Resolved]
private IAPIProvider api { get; set; }
/// <summary>
/// Perform an API lookup on the specified beatmap, populating a <see cref="APIBeatmap"/> model.
/// </summary>
@ -27,7 +20,7 @@ namespace osu.Game.Database
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated beatmap, or null if the beatmap does not exist or the request could not be satisfied.</returns>
[ItemCanBeNull]
public Task<APIBeatmap> GetBeatmapAsync(int beatmapId, CancellationToken token = default) => GetAsync(beatmapId, token);
public Task<APIBeatmap> GetBeatmapAsync(int beatmapId, CancellationToken token = default) => LookupAsync(beatmapId, token);
/// <summary>
/// Perform an API lookup on the specified beatmaps, populating a <see cref="APIBeatmap"/> model.
@ -35,115 +28,10 @@ namespace osu.Game.Database
/// <param name="beatmapIds">The beatmaps to lookup.</param>
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated beatmaps. May include null results for failed retrievals.</returns>
public Task<APIBeatmap[]> GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default)
{
var beatmapLookupTasks = new List<Task<APIBeatmap>>();
public Task<APIBeatmap[]> GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default) => LookupAsync(beatmapIds, token);
foreach (int u in beatmapIds)
{
beatmapLookupTasks.Add(GetBeatmapAsync(u, token).ContinueWith(task =>
{
if (!task.IsCompletedSuccessfully)
return null;
protected override GetBeatmapsRequest CreateRequest(IEnumerable<int> ids) => new GetBeatmapsRequest(ids.ToArray());
return task.Result;
}, token));
}
return Task.WhenAll(beatmapLookupTasks);
}
protected override async Task<APIBeatmap> ComputeValueAsync(int lookup, CancellationToken token = default)
=> await queryBeatmap(lookup).ConfigureAwait(false);
private readonly Queue<(int id, TaskCompletionSource<APIBeatmap>)> pendingBeatmapTasks = new Queue<(int, TaskCompletionSource<APIBeatmap>)>();
private Task pendingRequestTask;
private readonly object taskAssignmentLock = new object();
private Task<APIBeatmap> queryBeatmap(int beatmapId)
{
lock (taskAssignmentLock)
{
var tcs = new TaskCompletionSource<APIBeatmap>();
// Add to the queue.
pendingBeatmapTasks.Enqueue((beatmapId, tcs));
// Create a request task if there's not already one.
if (pendingRequestTask == null)
createNewTask();
return tcs.Task;
}
}
private void performLookup()
{
// contains at most 50 unique beatmap IDs from beatmapTasks, which is used to perform the lookup.
var beatmapTasks = new Dictionary<int, List<TaskCompletionSource<APIBeatmap>>>();
// Grab at most 50 unique beatmap IDs from the queue.
lock (taskAssignmentLock)
{
while (pendingBeatmapTasks.Count > 0 && beatmapTasks.Count < 50)
{
(int id, TaskCompletionSource<APIBeatmap> task) next = pendingBeatmapTasks.Dequeue();
// Perform a secondary check for existence, in case the beatmap was queried in a previous batch.
if (CheckExists(next.id, out var existing))
next.task.SetResult(existing);
else
{
if (beatmapTasks.TryGetValue(next.id, out var tasks))
tasks.Add(next.task);
else
beatmapTasks[next.id] = new List<TaskCompletionSource<APIBeatmap>> { next.task };
}
}
}
if (beatmapTasks.Count == 0)
return;
// Query the beatmaps.
var request = new GetBeatmapsRequest(beatmapTasks.Keys.ToArray());
// rather than queueing, we maintain our own single-threaded request stream.
// todo: we probably want retry logic here.
api.Perform(request);
// Create a new request task if there's still more beatmaps to query.
lock (taskAssignmentLock)
{
pendingRequestTask = null;
if (pendingBeatmapTasks.Count > 0)
createNewTask();
}
List<APIBeatmap> foundBeatmaps = request.Response?.Beatmaps;
if (foundBeatmaps != null)
{
foreach (var beatmap in foundBeatmaps)
{
if (beatmapTasks.TryGetValue(beatmap.OnlineID, out var tasks))
{
foreach (var task in tasks)
task.SetResult(beatmap);
beatmapTasks.Remove(beatmap.OnlineID);
}
}
}
// if any tasks remain which were not satisfied, return null.
foreach (var tasks in beatmapTasks.Values)
{
foreach (var task in tasks)
task.SetResult(null);
}
}
private void createNewTask() => pendingRequestTask = Task.Run(performLookup);
protected override IEnumerable<APIBeatmap> RetrieveResults(GetBeatmapsRequest request) => request.Response?.Beatmaps;
}
}

View File

@ -0,0 +1,170 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Game.Online.API;
namespace osu.Game.Database
{
public abstract class OnlineLookupCache<TLookup, TValue, TRequest> : MemoryCachingComponent<TLookup, TValue>
where TLookup : IEquatable<TLookup>
where TValue : class, IHasOnlineID<TLookup>
where TRequest : APIRequest
{
[Resolved]
private IAPIProvider api { get; set; }
/// <summary>
/// Creates an <see cref="APIRequest"/> to retrieve the values for a given collection of <typeparamref name="TLookup"/>s.
/// </summary>
/// <param name="ids">The IDs to perform the lookup with.</param>
protected abstract TRequest CreateRequest(IEnumerable<TLookup> ids);
/// <summary>
/// Retrieves a list of <typeparamref name="TValue"/>s from a successful <typeparamref name="TRequest"/> created by <see cref="CreateRequest"/>.
/// </summary>
[CanBeNull]
protected abstract IEnumerable<TValue> RetrieveResults(TRequest request);
/// <summary>
/// Perform a lookup using the specified <paramref name="id"/>, populating a <typeparamref name="TValue"/>.
/// </summary>
/// <param name="id">The ID to lookup.</param>
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated <typeparamref name="TValue"/>, or null if the value does not exist or the request could not be satisfied.</returns>
[ItemCanBeNull]
protected Task<TValue> LookupAsync(TLookup id, CancellationToken token = default) => GetAsync(id, token);
/// <summary>
/// Perform an API lookup on the specified <paramref name="ids"/>, populating a <typeparamref name="TValue"/>.
/// </summary>
/// <param name="ids">The IDs to lookup.</param>
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated values. May include null results for failed retrievals.</returns>
protected Task<TValue[]> LookupAsync(TLookup[] ids, CancellationToken token = default)
{
var lookupTasks = new List<Task<TValue>>();
foreach (var id in ids)
{
lookupTasks.Add(LookupAsync(id, token).ContinueWith(task =>
{
if (!task.IsCompletedSuccessfully)
return null;
return task.Result;
}, token));
}
return Task.WhenAll(lookupTasks);
}
// cannot be sealed due to test usages (see TestUserLookupCache).
protected override async Task<TValue> ComputeValueAsync(TLookup lookup, CancellationToken token = default)
=> await queryValue(lookup).ConfigureAwait(false);
private readonly Queue<(TLookup id, TaskCompletionSource<TValue>)> pendingTasks = new Queue<(TLookup, TaskCompletionSource<TValue>)>();
private Task pendingRequestTask;
private readonly object taskAssignmentLock = new object();
private Task<TValue> queryValue(TLookup id)
{
lock (taskAssignmentLock)
{
var tcs = new TaskCompletionSource<TValue>();
// Add to the queue.
pendingTasks.Enqueue((id, tcs));
// Create a request task if there's not already one.
if (pendingRequestTask == null)
createNewTask();
return tcs.Task;
}
}
private void performLookup()
{
// contains at most 50 unique IDs from tasks, which is used to perform the lookup.
var nextTaskBatch = new Dictionary<TLookup, List<TaskCompletionSource<TValue>>>();
// Grab at most 50 unique IDs from the queue.
lock (taskAssignmentLock)
{
while (pendingTasks.Count > 0 && nextTaskBatch.Count < 50)
{
(TLookup id, TaskCompletionSource<TValue> task) next = pendingTasks.Dequeue();
// Perform a secondary check for existence, in case the value was queried in a previous batch.
if (CheckExists(next.id, out var existing))
next.task.SetResult(existing);
else
{
if (nextTaskBatch.TryGetValue(next.id, out var tasks))
tasks.Add(next.task);
else
nextTaskBatch[next.id] = new List<TaskCompletionSource<TValue>> { next.task };
}
}
}
if (nextTaskBatch.Count == 0)
{
finishPendingTask();
return;
}
// Query the values.
var request = CreateRequest(nextTaskBatch.Keys.ToArray());
// rather than queueing, we maintain our own single-threaded request stream.
// todo: we probably want retry logic here.
api.Perform(request);
finishPendingTask();
var foundValues = RetrieveResults(request);
if (foundValues != null)
{
foreach (var value in foundValues)
{
if (nextTaskBatch.TryGetValue(value.OnlineID, out var tasks))
{
foreach (var task in tasks)
task.SetResult(value);
nextTaskBatch.Remove(value.OnlineID);
}
}
}
// if any tasks remain which were not satisfied, return null.
foreach (var tasks in nextTaskBatch.Values)
{
foreach (var task in tasks)
task.SetResult(null);
}
}
private void finishPendingTask()
{
// Create a new request task if there's still more values to query.
lock (taskAssignmentLock)
{
pendingRequestTask = null;
if (pendingTasks.Count > 0)
createNewTask();
}
}
private void createNewTask() => pendingRequestTask = Task.Run(performLookup);
}
}

View File

@ -6,18 +6,13 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Database
{
public class UserLookupCache : MemoryCachingComponent<int, APIUser>
public class UserLookupCache : OnlineLookupCache<int, APIUser, GetUsersRequest>
{
[Resolved]
private IAPIProvider api { get; set; }
/// <summary>
/// Perform an API lookup on the specified user, populating a <see cref="APIUser"/> model.
/// </summary>
@ -25,7 +20,7 @@ namespace osu.Game.Database
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated user, or null if the user does not exist or the request could not be satisfied.</returns>
[ItemCanBeNull]
public Task<APIUser> GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token);
public Task<APIUser> GetUserAsync(int userId, CancellationToken token = default) => LookupAsync(userId, token);
/// <summary>
/// Perform an API lookup on the specified users, populating a <see cref="APIUser"/> model.
@ -33,115 +28,10 @@ namespace osu.Game.Database
/// <param name="userIds">The users to lookup.</param>
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated users. May include null results for failed retrievals.</returns>
public Task<APIUser[]> GetUsersAsync(int[] userIds, CancellationToken token = default)
{
var userLookupTasks = new List<Task<APIUser>>();
public Task<APIUser[]> GetUsersAsync(int[] userIds, CancellationToken token = default) => LookupAsync(userIds, token);
foreach (int u in userIds)
{
userLookupTasks.Add(GetUserAsync(u, token).ContinueWith(task =>
{
if (!task.IsCompletedSuccessfully)
return null;
protected override GetUsersRequest CreateRequest(IEnumerable<int> ids) => new GetUsersRequest(ids.ToArray());
return task.Result;
}, token));
}
return Task.WhenAll(userLookupTasks);
}
protected override async Task<APIUser> ComputeValueAsync(int lookup, CancellationToken token = default)
=> await queryUser(lookup).ConfigureAwait(false);
private readonly Queue<(int id, TaskCompletionSource<APIUser>)> pendingUserTasks = new Queue<(int, TaskCompletionSource<APIUser>)>();
private Task pendingRequestTask;
private readonly object taskAssignmentLock = new object();
private Task<APIUser> queryUser(int userId)
{
lock (taskAssignmentLock)
{
var tcs = new TaskCompletionSource<APIUser>();
// Add to the queue.
pendingUserTasks.Enqueue((userId, tcs));
// Create a request task if there's not already one.
if (pendingRequestTask == null)
createNewTask();
return tcs.Task;
}
}
private void performLookup()
{
// contains at most 50 unique user IDs from userTasks, which is used to perform the lookup.
var userTasks = new Dictionary<int, List<TaskCompletionSource<APIUser>>>();
// Grab at most 50 unique user IDs from the queue.
lock (taskAssignmentLock)
{
while (pendingUserTasks.Count > 0 && userTasks.Count < 50)
{
(int id, TaskCompletionSource<APIUser> task) next = pendingUserTasks.Dequeue();
// Perform a secondary check for existence, in case the user was queried in a previous batch.
if (CheckExists(next.id, out var existing))
next.task.SetResult(existing);
else
{
if (userTasks.TryGetValue(next.id, out var tasks))
tasks.Add(next.task);
else
userTasks[next.id] = new List<TaskCompletionSource<APIUser>> { next.task };
}
}
}
if (userTasks.Count == 0)
return;
// Query the users.
var request = new GetUsersRequest(userTasks.Keys.ToArray());
// rather than queueing, we maintain our own single-threaded request stream.
// todo: we probably want retry logic here.
api.Perform(request);
// Create a new request task if there's still more users to query.
lock (taskAssignmentLock)
{
pendingRequestTask = null;
if (pendingUserTasks.Count > 0)
createNewTask();
}
List<APIUser> foundUsers = request.Response?.Users;
if (foundUsers != null)
{
foreach (var user in foundUsers)
{
if (userTasks.TryGetValue(user.Id, out var tasks))
{
foreach (var task in tasks)
task.SetResult(user);
userTasks.Remove(user.Id);
}
}
}
// if any tasks remain which were not satisfied, return null.
foreach (var tasks in userTasks.Values)
{
foreach (var task in tasks)
task.SetResult(null);
}
}
private void createNewTask() => pendingRequestTask = Task.Run(performLookup);
protected override IEnumerable<APIUser> RetrieveResults(GetUsersRequest request) => request.Response?.Users;
}
}

View File

@ -1,6 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
using System.Collections.Generic;
using System.Linq;
using Humanizer;
using Humanizer.Localisation;
@ -10,6 +13,33 @@ namespace osu.Game.Online.Rooms
{
public static class PlaylistExtensions
{
/// <summary>
/// Returns all historical/expired items from the <paramref name="playlist"/>, in the order in which they were played.
/// </summary>
public static IEnumerable<PlaylistItem> GetHistoricalItems(this IEnumerable<PlaylistItem> playlist)
=> playlist.Where(item => item.Expired).OrderBy(item => item.PlayedAt);
/// <summary>
/// Returns all non-expired items from the <paramref name="playlist"/>, in the order in which they are to be played.
/// </summary>
public static IEnumerable<PlaylistItem> GetUpcomingItems(this IEnumerable<PlaylistItem> playlist)
=> playlist.Where(item => !item.Expired).OrderBy(item => item.PlaylistOrder);
/// <summary>
/// Returns the first non-expired <see cref="PlaylistItem"/> in playlist order from the supplied <paramref name="playlist"/>,
/// or the last-played <see cref="PlaylistItem"/> if all items are expired,
/// or <see langword="null"/> if <paramref name="playlist"/> was empty.
/// </summary>
public static PlaylistItem? GetCurrentItem(this ICollection<PlaylistItem> playlist)
{
if (playlist.Count == 0)
return null;
return playlist.All(item => item.Expired)
? GetHistoricalItems(playlist).Last()
: GetUpcomingItems(playlist).First();
}
public static string GetTotalDuration(this BindableList<PlaylistItem> playlist) =>
playlist.Select(p => p.Beatmap.Value.Length).Sum().Milliseconds().Humanize(minUnit: TimeUnit.Second, maxUnit: TimeUnit.Hour, precision: 2);
}

View File

@ -680,7 +680,7 @@ namespace osu.Game
sessionIdleTracker.IsIdle.BindValueChanged(idle =>
{
if (idle.NewValue)
SessionStatics.ResetValues();
SessionStatics.ResetAfterInactivity();
});
Add(sessionIdleTracker);

View File

@ -33,7 +33,7 @@ namespace osu.Game.Overlays
private Drawable currentContent;
private Container panelTarget;
private FillFlowContainer<BeatmapCard> foundContent;
private FillFlowContainer<BeatmapCardNormal> foundContent;
private NotFoundDrawable notFoundContent;
private SupporterRequiredDrawable supporterRequiredContent;
private BeatmapListingFilterControl filterControl;
@ -78,7 +78,7 @@ namespace osu.Game.Overlays
Padding = new MarginPadding { Horizontal = 20 },
Children = new Drawable[]
{
foundContent = new FillFlowContainer<BeatmapCard>(),
foundContent = new FillFlowContainer<BeatmapCardNormal>(),
notFoundContent = new NotFoundDrawable(),
supporterRequiredContent = new SupporterRequiredDrawable(),
}
@ -135,7 +135,7 @@ namespace osu.Game.Overlays
return;
}
var newPanels = searchResult.Results.Select(b => new BeatmapCard(b)
var newPanels = searchResult.Results.Select(b => new BeatmapCardNormal(b)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
@ -152,7 +152,7 @@ namespace osu.Game.Overlays
// spawn new children with the contained so we only clear old content at the last moment.
// reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most).
var content = new ReverseChildIDFillFlowContainer<BeatmapCard>
var content = new ReverseChildIDFillFlowContainer<BeatmapCardNormal>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
@ -217,6 +217,10 @@ namespace osu.Game.Overlays
public class NotFoundDrawable : CompositeDrawable
{
// required for scheduled tasks to complete correctly
// (see `addContentToPlaceholder()` and the scheduled `BypassAutoSizeAxes` set during fade-out in outer class above)
public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks;
public NotFoundDrawable()
{
RelativeSizeAxes = Axes.X;
@ -261,6 +265,10 @@ namespace osu.Game.Overlays
// (https://github.com/ppy/osu-framework/issues/4530)
public class SupporterRequiredDrawable : CompositeDrawable
{
// required for scheduled tasks to complete correctly
// (see `addContentToPlaceholder()` and the scheduled `BypassAutoSizeAxes` set during fade-out in outer class above)
public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks;
private LinkFlowContainer supporterRequiredText;
public SupporterRequiredDrawable()

View File

@ -61,7 +61,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps
new GetUserBeatmapsRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage);
protected override Drawable CreateDrawableItem(APIBeatmapSet model) => model.OnlineID > 0
? new BeatmapCard(model)
? new BeatmapCardNormal(model)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,

View File

@ -136,12 +136,12 @@ namespace osu.Game.Overlays.Rankings
{
new ScoresTable(1, response.Users),
// reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most).
new ReverseChildIDFillFlowContainer<BeatmapCard>
new ReverseChildIDFillFlowContainer<BeatmapCardNormal>
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Spacing = new Vector2(10),
Children = response.BeatmapSets.Select(b => new BeatmapCard(b)
Children = response.BeatmapSets.Select(b => new BeatmapCardNormal(b)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Difficulty
protected const int ATTRIB_ID_OVERALL_DIFFICULTY = 5;
protected const int ATTRIB_ID_APPROACH_RATE = 7;
protected const int ATTRIB_ID_MAX_COMBO = 9;
protected const int ATTRIB_ID_STRAIN = 11;
protected const int ATTRIB_ID_DIFFICULTY = 11;
protected const int ATTRIB_ID_GREAT_HIT_WINDOW = 13;
protected const int ATTRIB_ID_SCORE_MULTIPLIER = 15;
protected const int ATTRIB_ID_FLASHLIGHT = 17;

View File

@ -0,0 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
namespace osu.Game.Rulesets.Difficulty
{
public class PerformanceAttributes
{
/// <summary>
/// Calculated score performance points.
/// </summary>
[JsonProperty("pp")]
public double Total { get; set; }
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions;
@ -37,6 +36,6 @@ namespace osu.Game.Rulesets.Difficulty
TimeRate = track.Rate;
}
public abstract double Calculate(Dictionary<string, double> categoryDifficulty = null);
public abstract PerformanceAttributes Calculate();
}
}

View File

@ -44,7 +44,7 @@ namespace osu.Game.Scoring
var calculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(attributes.Value.Attributes, score);
return calculator?.Calculate();
return calculator?.Calculate().Total;
}
public readonly struct PerformanceCacheLookup

View File

@ -177,8 +177,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
else
circle.Colour = colour;
var col = circle.Colour.TopLeft.Linear;
colouredComponents.Colour = OsuColour.ForegroundTextColourFor(col);
var averageColour = Interpolation.ValueAt(0.5, circle.Colour.TopLeft, circle.Colour.TopRight, 0, 1);
colouredComponents.Colour = OsuColour.ForegroundTextColourFor(averageColour);
}
private SamplePointPiece sampleOverrideDisplay;

View File

@ -1,10 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Components
{
@ -30,7 +30,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
private void updateBeatmap()
{
sprite.Beatmap.Value = Playlist.FirstOrDefault()?.Beatmap.Value;
sprite.Beatmap.Value = Playlist.GetCurrentItem()?.Beatmap.Value;
}
protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(BeatmapSetCoverType) { RelativeSizeAxes = Axes.Both };

View File

@ -3,7 +3,6 @@
#nullable enable
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Screens;
using osu.Game.Online.Rooms;
@ -20,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge
public LoungeBackgroundScreen()
{
SelectedRoom.BindValueChanged(onSelectedRoomChanged);
playlist.BindCollectionChanged((_, __) => PlaylistItem = playlist.FirstOrDefault());
playlist.BindCollectionChanged((_, __) => PlaylistItem = playlist.GetCurrentItem());
}
private void onSelectedRoomChanged(ValueChangedEvent<Room> room)

View File

@ -8,7 +8,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osuTK;
@ -54,12 +53,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
[Resolved]
private MultiplayerClient multiplayerClient { get; set; }
[Resolved(typeof(Room), nameof(Room.Host))]
private Bindable<APIUser> host { get; set; }
[Resolved(typeof(Room), nameof(Room.QueueMode))]
private Bindable<QueueMode> queueMode { get; set; }
public QueuePlaylistItem(PlaylistItem item)
: base(item)
{
@ -71,17 +64,29 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID);
host.BindValueChanged(_ => updateDeleteButtonVisibility());
queueMode.BindValueChanged(_ => updateDeleteButtonVisibility());
SelectedItem.BindValueChanged(_ => updateDeleteButtonVisibility(), true);
multiplayerClient.RoomUpdated += onRoomUpdated;
onRoomUpdated();
}
private void onRoomUpdated() => Scheduler.AddOnce(updateDeleteButtonVisibility);
private void updateDeleteButtonVisibility()
{
if (multiplayerClient.Room == null)
return;
bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost;
AllowDeletion = isItemOwner && SelectedItem.Value != Item;
AllowEditing = isItemOwner;
AllowDeletion = isItemOwner && !Item.Expired && Item.ID != multiplayerClient.Room.Settings.PlaylistItemId;
AllowEditing = isItemOwner && !Item.Expired;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (multiplayerClient != null)
multiplayerClient.RoomUpdated -= onRoomUpdated;
}
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
@ -434,6 +435,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void handleRoomLost() => Schedule(() =>
{
Logger.Log($"{this} exiting due to loss of room or connection");
if (this.IsCurrentScreen())
this.Exit();
else

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
@ -73,7 +72,7 @@ namespace osu.Game.Screens.OnlinePlay
private IBindable<PlaylistItem> subScreenSelectedItem { get; set; }
/// <summary>
/// The currently selected item in the <see cref="RoomSubScreen"/>, or the last item from <see cref="Playlist"/>
/// The currently selected item in the <see cref="RoomSubScreen"/>, or the current item from <see cref="Playlist"/>
/// if this <see cref="OnlinePlayComposite"/> is not within a <see cref="RoomSubScreen"/>.
/// </summary>
protected readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>();
@ -88,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay
protected virtual void UpdateSelectedItem()
=> SelectedItem.Value = RoomID.Value == null || subScreenSelectedItem == null
? Playlist.LastOrDefault()
? Playlist.GetCurrentItem()
: subScreenSelectedItem.Value;
}
}

View File

@ -95,6 +95,8 @@ namespace osu.Game.Screens.OnlinePlay
private void forcefullyExit()
{
Logger.Log($"{this} forcefully exiting due to loss of API connection");
// This is temporary since we don't currently have a way to force screens to be exited
if (this.IsCurrentScreen())
{

View File

@ -40,9 +40,16 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
if (gameplayClockContainer != null)
gameplayClockContainer.OnSeek += Clear;
processor.NewJudgement += OnNewJudgement;
processor.NewJudgement += processorNewJudgement;
}
// Scheduled as meter implementations are likely going to change/add drawables when reacting to this.
private void processorNewJudgement(JudgementResult j) => Schedule(() => OnNewJudgement(j));
/// <summary>
/// Fired when a new judgement arrives.
/// </summary>
/// <param name="judgement">The new judgement.</param>
protected abstract void OnNewJudgement(JudgementResult judgement);
protected Color4 GetColourForHitResult(HitResult result)
@ -84,7 +91,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
base.Dispose(isDisposing);
if (processor != null)
processor.NewJudgement -= OnNewJudgement;
processor.NewJudgement -= processorNewJudgement;
if (gameplayClockContainer != null)
gameplayClockContainer.OnSeek -= Clear;

View File

@ -129,7 +129,7 @@ namespace osu.Game.Screens.Play.HUD
var calculator = gameplayState.Ruleset.CreatePerformanceCalculator(attrib, scoreInfo);
Current.Value = (int)Math.Round(calculator?.Calculate() ?? 0, MidpointRounding.AwayFromZero);
Current.Value = (int)Math.Round(calculator?.Calculate().Total ?? 0, MidpointRounding.AwayFromZero);
IsValid = true;
}

View File

@ -228,7 +228,7 @@ namespace osu.Game.Screens.Play
onlineBeatmapRequest.Success += beatmapSet => Schedule(() =>
{
this.beatmapSet = beatmapSet;
beatmapPanelContainer.Child = new BeatmapCard(this.beatmapSet, allowExpansion: false);
beatmapPanelContainer.Child = new BeatmapCardNormal(this.beatmapSet, allowExpansion: false);
checkForAutomaticDownload();
});

View File

@ -115,7 +115,9 @@ namespace osu.Game.Screens.Select.Leaderboards
var cancellationToken = loadCancellationSource.Token;
if (BeatmapInfo == null)
var fetchBeatmapInfo = BeatmapInfo;
if (fetchBeatmapInfo == null)
{
PlaceholderState = PlaceholderState.NoneSelected;
return null;
@ -124,7 +126,7 @@ namespace osu.Game.Screens.Select.Leaderboards
if (Scope == BeatmapLeaderboardScope.Local)
{
var scores = scoreManager
.QueryScores(s => !s.DeletePending && s.BeatmapInfo.ID == BeatmapInfo.ID && s.Ruleset.ID == ruleset.Value.ID);
.QueryScores(s => !s.DeletePending && s.BeatmapInfo.ID == fetchBeatmapInfo.ID && s.Ruleset.ID == ruleset.Value.ID);
if (filterMods && !mods.Value.Any())
{
@ -151,7 +153,7 @@ namespace osu.Game.Screens.Select.Leaderboards
return null;
}
if (BeatmapInfo.OnlineID == null || BeatmapInfo?.Status <= BeatmapOnlineStatus.Pending)
if (fetchBeatmapInfo.OnlineID == null || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending)
{
PlaceholderState = PlaceholderState.Unavailable;
return null;
@ -171,18 +173,18 @@ namespace osu.Game.Screens.Select.Leaderboards
else if (filterMods)
requestMods = mods.Value;
var req = new GetScoresRequest(BeatmapInfo, ruleset.Value ?? BeatmapInfo.Ruleset, Scope, requestMods);
var req = new GetScoresRequest(fetchBeatmapInfo, ruleset.Value ?? fetchBeatmapInfo.Ruleset, Scope, requestMods);
req.Success += r =>
{
scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets, BeatmapInfo)).ToArray(), cancellationToken)
scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets, fetchBeatmapInfo)).ToArray(), cancellationToken)
.ContinueWith(ordered => Schedule(() =>
{
if (cancellationToken.IsCancellationRequested)
return;
scoresCallback?.Invoke(ordered.Result);
TopScore = r.UserScore?.CreateScoreInfo(rulesets, BeatmapInfo);
TopScore = r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo);
}), TaskContinuationOptions.OnlyOnRanToCompletion);
};

View File

@ -17,9 +17,10 @@ namespace osu.Game.Tests
/// <param name="gameSuffix">An optional suffix which will isolate this host from others called from the same method source.</param>
/// <param name="bindIPC">Whether to bind IPC channels.</param>
/// <param name="realtime">Whether the host should be forced to run in realtime, rather than accelerated test time.</param>
/// <param name="bypassCleanup">Whether to bypass directory cleanup on host disposal. Should be used only if a subsequent test relies on the files still existing.</param>
/// <param name="callingMethodName">The name of the calling method, used for test file isolation and clean-up.</param>
public CleanRunHeadlessGameHost(string gameSuffix = @"", bool bindIPC = false, bool realtime = true, [CallerMemberName] string callingMethodName = @"")
: base(callingMethodName + gameSuffix, bindIPC, realtime)
public CleanRunHeadlessGameHost(string gameSuffix = @"", bool bindIPC = false, bool realtime = true, bool bypassCleanup = false, [CallerMemberName] string callingMethodName = @"")
: base(callingMethodName + gameSuffix, bindIPC, realtime, bypassCleanup: bypassCleanup)
{
}

View File

@ -1,9 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Framework.Testing.Input;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.Sprites;
@ -11,6 +14,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tests.Visual
{
@ -115,6 +119,25 @@ namespace osu.Game.Tests.Visual
});
}
/// <summary>
/// Wait for a button to become enabled, then click it.
/// </summary>
/// <typeparam name="T"></typeparam>
protected void ClickButtonWhenEnabled<T>()
where T : Drawable
{
if (typeof(T) == typeof(Button))
AddUntilStep($"wait for {typeof(T).Name} enabled", () => (this.ChildrenOfType<T>().Single() as Button)?.Enabled.Value == true);
else
AddUntilStep($"wait for {typeof(T).Name} enabled", () => this.ChildrenOfType<T>().Single().ChildrenOfType<Button>().Single().Enabled.Value);
AddStep($"click {typeof(T).Name}", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<T>().Single());
InputManager.Click(MouseButton.Left);
});
}
protected override void Update()
{
base.Update();

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.7.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1220.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1221.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1215.0" />
<PackageReference Include="Sentry" Version="3.12.1" />
<PackageReference Include="SharpCompress" Version="0.30.1" />

View File

@ -60,7 +60,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1220.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1221.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1215.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@ -83,7 +83,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1220.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1221.0" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" />