1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-13 12:53:11 +08:00

Merge branch 'master' into ruleset-localization

This commit is contained in:
Nao 2022-08-14 15:17:44 -04:00 committed by GitHub
commit c940f5abcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 347 additions and 220 deletions

View File

@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "CS";
public override double ScoreMultiplier => 1;
public override double ScoreMultiplier => 0.9;
public override LocalisableString Description => "No more tricky speed changes!";

View File

@ -17,18 +17,18 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.6369583000323935d, 206, "diffcalc-test")]
[TestCase(1.4476531024675374d, 45, "zero-length-sliders")]
[TestCase(6.7115569159190587d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.8816128335486386d, 206, "diffcalc-test")]
[TestCase(1.7540389962596916d, 45, "zero-length-sliders")]
[TestCase(8.9757300665532966d, 206, "diffcalc-test")]
[TestCase(1.7437232654020756d, 45, "zero-length-sliders")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.6369583000323935d, 239, "diffcalc-test")]
[TestCase(1.4476531024675374d, 54, "zero-length-sliders")]
[TestCase(6.7115569159190587d, 239, "diffcalc-test")]
[TestCase(1.4391311903612753d, 54, "zero-length-sliders")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -13,8 +13,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public static class AimEvaluator
{
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.0;
private const double slider_multiplier = 1.5;
private const double acute_angle_multiplier = 1.95;
private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75;
/// <summary>
@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2);
}
if (osuLastObj.TravelTime != 0)
if (osuLastObj.BaseObject is Slider)
{
// Reward sliders based on velocity.
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;

View File

@ -15,11 +15,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double max_opacity_bonus = 0.4;
private const double hidden_bonus = 0.2;
private const double min_velocity = 0.5;
private const double slider_multiplier = 1.3;
/// <summary>
/// Evaluates the difficulty of memorising and hitting an object, based on:
/// <list type="bullet">
/// <item><description>distance between the previous and current object,</description></item>
/// <item><description>distance between a number of previous objects and the current object,</description></item>
/// <item><description>the visual opacity of the current object,</description></item>
/// <item><description>length and speed of the current object (for sliders),</description></item>
/// <item><description>and whether the hidden mod is enabled.</description></item>
/// </list>
/// </summary>
@ -73,6 +77,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (hidden)
result *= 1.0 + hidden_bonus;
double sliderBonus = 0.0;
if (osuCurrent.BaseObject is Slider osuSlider)
{
// Invert the scaling factor to determine the true travel distance independent of circle size.
double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor;
// Reward sliders based on velocity.
sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5);
// Longer sliders require more memorisation.
sliderBonus *= pixelTravelDistance;
// Nerf sliders with repeats, as less memorisation is required.
if (osuSlider.RepeatCount > 0)
sliderBonus /= (osuSlider.RepeatCount + 1);
}
result += sliderBonus * slider_multiplier;
return result;
}
}

View File

@ -46,7 +46,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
if (mods.Any(h => h is OsuModRelax))
{
aimRating *= 0.9;
speedRating = 0.0;
flashlightRating *= 0.7;
}
double baseAimPerformance = Math.Pow(5 * Math.Max(1, aimRating / 0.0675) - 4, 3) / 100000;
double baseSpeedPerformance = Math.Pow(5 * Math.Max(1, speedRating / 0.0675) - 4, 3) / 100000;
@ -62,7 +66,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
);
double starRating = basePerformance > 0.00001 ? Math.Cbrt(1.12) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0;
double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0;
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate;

View File

@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuPerformanceCalculator : PerformanceCalculator
{
public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
private double accuracy;
private int scoreMaxCombo;
private int countGreat;
@ -41,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
effectiveMissCount = calculateEffectiveMissCount(osuAttributes);
double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
double multiplier = PERFORMANCE_BASE_MULTIPLIER;
if (score.Mods.Any(m => m is OsuModNoFail))
multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount);
@ -51,10 +53,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax))
{
// As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
effectiveMissCount = Math.Min(effectiveMissCount + countOk + countMeh, totalHits);
// https://www.desmos.com/calculator/bc9eybdthb
// we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
// this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
double okMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 1.8) : 1.0);
double mehMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 5) : 1.0);
multiplier *= 0.6;
// As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits);
}
double aimValue = computeAimValue(score, osuAttributes);
@ -103,7 +109,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (attributes.ApproachRate > 10.33)
approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33);
else if (attributes.ApproachRate < 8.0)
approachRateFactor = 0.1 * (8.0 - attributes.ApproachRate);
approachRateFactor = 0.05 * (8.0 - attributes.ApproachRate);
if (score.Mods.Any(h => h is OsuModRelax))
approachRateFactor = 0.0;
aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
@ -134,6 +143,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
if (score.Mods.Any(h => h is OsuModRelax))
return 0.0;
double speedValue = Math.Pow(5.0 * Math.Max(1.0, attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0;
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
@ -174,7 +186,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2);
// Scale the speed value with # of 50s to punish doubletapping.
speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
return speedValue;
}
@ -266,6 +278,5 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => 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

@ -15,10 +15,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{
public class OsuDifficultyHitObject : DifficultyHitObject
{
private const int normalised_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
/// <summary>
/// A distance by which all distances should be scaled in order to assume a uniform circle size.
/// </summary>
public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
private const int min_delta_time = 25;
private const float maximum_slider_radius = normalised_radius * 2.4f;
private const float assumed_slider_radius = normalised_radius * 1.8f;
private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f;
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;
protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject;
@ -64,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
public double TravelDistance { get; private set; }
/// <summary>
/// The time taken to travel through <see cref="TravelDistance"/>, with a minimum value of 25ms for a non-zero distance.
/// The time taken to travel through <see cref="TravelDistance"/>, with a minimum value of 25ms for <see cref="Slider"/> objects.
/// </summary>
public double TravelTime { get; private set; }
@ -123,7 +127,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (BaseObject is Slider currentSlider)
{
computeSliderCursorPosition(currentSlider);
TravelDistance = currentSlider.LazyTravelDistance;
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time);
}
@ -132,7 +137,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
return;
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = normalised_radius / (float)BaseObject.Radius;
float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius;
if (BaseObject.Radius < 30)
{
@ -206,7 +211,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived.
var currCursorPosition = slider.StackedPosition;
double scalingFactor = normalised_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used.
double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used.
for (int i = 1; i < slider.NestedHitObjects.Count; i++)
{
@ -234,7 +239,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
else if (currMovementObj is SliderRepeat)
{
// For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
requiredMovement = normalised_radius;
requiredMovement = NORMALISED_RADIUS;
}
if (currMovementLength > requiredMovement)
@ -248,8 +253,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (i == slider.NestedHitObjects.Count - 1)
slider.LazyEndPosition = currCursorPosition;
}
slider.LazyTravelDistance *= (float)Math.Pow(1 + slider.RepeatCount / 2.5, 1.0 / 2.5); // Bonus for repeat sliders until a better per nested object strain system can be achieved.
}
private Vector2 getEndCursorPosition(OsuHitObject hitObject)

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain;
private double skillMultiplier => 23.25;
private double skillMultiplier => 23.55;
private double strainDecayBase => 0.15;
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => OsuIcon.ModAutopilot;
public override ModType Type => ModType.Automation;
public override LocalisableString Description => @"Automatic cursor movement - just follow the rhythm.";
public override double ScoreMultiplier => 1;
public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModRepel) };
public bool PerformFail() => false;

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public override IconUsage? Icon => FontAwesome.Solid.Magnet;
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "No need to chase the circles your cursor is a magnet!";
public override double ScoreMultiplier => 1;
public override double ScoreMultiplier => 0.5;
public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel) };
private IFrameStableClock gameplayClock = null!;

View File

@ -128,6 +128,8 @@ namespace osu.Game.Tests.Resources
var rulesetInfo = getRuleset();
string hash = Guid.NewGuid().ToString().ComputeMD5Hash();
yield return new BeatmapInfo
{
OnlineID = beatmapId,
@ -136,7 +138,8 @@ namespace osu.Game.Tests.Resources
Length = length,
BeatmapSet = beatmapSet,
BPM = bpm,
Hash = Guid.NewGuid().ToString().ComputeMD5Hash(),
Hash = hash,
MD5Hash = hash,
Ruleset = rulesetInfo,
Metadata = metadata.DeepClone(),
Difficulty = new BeatmapDifficulty

View File

@ -1,18 +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.
#nullable disable
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays.Music;
using osu.Game.Rulesets;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
@ -21,13 +23,25 @@ namespace osu.Game.Tests.Visual.UserInterface
{
public class TestScenePlaylistOverlay : OsuManualInputManagerTestScene
{
private readonly BindableList<Live<BeatmapSetInfo>> beatmapSets = new BindableList<Live<BeatmapSetInfo>>();
protected override bool UseFreshStoragePerRun => true;
private PlaylistOverlay playlistOverlay;
private PlaylistOverlay playlistOverlay = null!;
private Live<BeatmapSetInfo> first;
private BeatmapManager beatmapManager = null!;
private const int item_count = 100;
private const int item_count = 20;
private List<BeatmapSetInfo> beatmapSets => beatmapManager.GetAllUsableBeatmapSets();
[BackgroundDependencyLoader]
private void load(GameHost host)
{
Dependencies.Cache(new RealmRulesetStore(Realm));
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
}
[SetUp]
public void Setup() => Schedule(() =>
@ -46,16 +60,12 @@ namespace osu.Game.Tests.Visual.UserInterface
}
};
beatmapSets.Clear();
for (int i = 0; i < item_count; i++)
{
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo().ToLiveUnmanaged());
beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo());
}
first = beatmapSets.First();
playlistOverlay.BeatmapSets.BindTo(beatmapSets);
beatmapSets.First().ToLive(Realm);
});
[Test]
@ -70,9 +80,13 @@ namespace osu.Game.Tests.Visual.UserInterface
AddUntilStep("wait for animations to complete", () => !playlistOverlay.Transforms.Any());
PlaylistItem firstItem = null!;
AddStep("hold 1st item handle", () =>
{
var handle = this.ChildrenOfType<OsuRearrangeableListItem<Live<BeatmapSetInfo>>.PlaylistItemHandle>().First();
firstItem = this.ChildrenOfType<PlaylistItem>().First();
var handle = firstItem.ChildrenOfType<PlaylistItem.PlaylistItemHandle>().First();
InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre);
InputManager.PressButton(MouseButton.Left);
});
@ -83,7 +97,7 @@ namespace osu.Game.Tests.Visual.UserInterface
InputManager.MoveMouseTo(item.ScreenSpaceDrawQuad.BottomLeft);
});
AddAssert("song 1 is 5th", () => beatmapSets[4].Equals(first));
AddAssert("first is moved", () => playlistOverlay.ChildrenOfType<Playlist>().Single().Items.ElementAt(4).Value.Equals(firstItem.Model.Value));
AddStep("release handle", () => InputManager.ReleaseButton(MouseButton.Left));
}
@ -101,6 +115,68 @@ namespace osu.Game.Tests.Visual.UserInterface
() => playlistOverlay.ChildrenOfType<PlaylistItem>()
.Where(item => item.MatchingFilter)
.All(item => item.FilterTerms.Any(term => term.ToString().Contains("10"))));
AddStep("Import new non-matching beatmap", () =>
{
var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(1);
testBeatmapSetInfo.Beatmaps.Single().Metadata.Title = "no guid";
beatmapManager.Import(testBeatmapSetInfo);
});
AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh()));
AddAssert("results filtered correctly",
() => playlistOverlay.ChildrenOfType<PlaylistItem>()
.Where(item => item.MatchingFilter)
.All(item => item.FilterTerms.Any(term => term.ToString().Contains("10"))));
}
[Test]
public void TestCollectionFiltering()
{
NowPlayingCollectionDropdown collectionDropdown() => playlistOverlay.ChildrenOfType<NowPlayingCollectionDropdown>().Single();
AddStep("Add collection", () =>
{
Dependencies.Get<RealmAccess>().Write(r =>
{
r.RemoveAll<BeatmapCollection>();
r.Add(new BeatmapCollection("wang"));
});
});
AddUntilStep("wait for dropdown to have new collection", () => collectionDropdown().Items.Count() == 2);
AddStep("Filter to collection", () =>
{
collectionDropdown().Current.Value = collectionDropdown().Items.Last();
});
AddUntilStep("No items present", () => !playlistOverlay.ChildrenOfType<PlaylistItem>().Any(i => i.MatchingFilter));
AddStep("Import new non-matching beatmap", () =>
{
beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(1));
});
AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh()));
AddUntilStep("No items matching", () => !playlistOverlay.ChildrenOfType<PlaylistItem>().Any(i => i.MatchingFilter));
BeatmapSetInfo collectionAddedBeatmapSet = null!;
AddStep("Import new matching beatmap", () =>
{
collectionAddedBeatmapSet = TestResources.CreateTestBeatmapSetInfo(1);
beatmapManager.Import(collectionAddedBeatmapSet);
Realm.Write(r => r.All<BeatmapCollection>().First().BeatmapMD5Hashes.Add(collectionAddedBeatmapSet.Beatmaps.First().MD5Hash));
});
AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh()));
AddUntilStep("Only matching item",
() => playlistOverlay.ChildrenOfType<PlaylistItem>().Where(i => i.MatchingFilter).Select(i => i.Model.ID), () => Is.EquivalentTo(new[] { collectionAddedBeatmapSet.ID }));
}
}
}

View File

@ -17,7 +17,7 @@ namespace osu.Game.Input.Bindings
private InputManager? parentInputManager;
public GlobalActionContainer(OsuGameBase game)
public GlobalActionContainer(OsuGameBase? game)
: base(matchingMode: KeyCombinationMatchingMode.Modifiers)
{
if (game is IKeyBindingHandler<GlobalAction>)
@ -60,6 +60,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay),
new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor),
new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.ToggleProfile),
new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings),
@ -339,5 +340,8 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleFPSCounter))]
ToggleFPSDisplay,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleProfile))]
ToggleProfile,
}
}

View File

@ -149,6 +149,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString ToggleNotifications => new TranslatableString(getKey(@"toggle_notifications"), @"Toggle notifications");
/// <summary>
/// "Toggle profile"
/// </summary>
public static LocalisableString ToggleProfile => new TranslatableString(getKey(@"toggle_profile"), @"Toggle profile");
/// <summary>
/// "Pause gameplay"
/// </summary>

View File

@ -104,131 +104,39 @@ namespace osu.Game.Online.API
/// </summary>
private int failureCount;
/// <summary>
/// The main API thread loop, which will continue to run until the game is shut down.
/// </summary>
private void run()
{
while (!cancellationToken.IsCancellationRequested)
{
switch (State.Value)
if (state.Value == APIState.Failing)
{
case APIState.Failing:
//todo: replace this with a ping request.
log.Add(@"In a failing state, waiting a bit before we try again...");
Thread.Sleep(5000);
// To recover from a failing state, falling through and running the full reconnection process seems safest for now.
// This could probably be replaced with a ping-style request if we want to avoid the reconnection overheads.
log.Add($@"{nameof(APIAccess)} is in a failing state, waiting a bit before we try again...");
Thread.Sleep(5000);
}
if (!IsLoggedIn) goto case APIState.Connecting;
// Ensure that we have valid credentials.
// If not, setting the offline state will allow the game to prompt the user to provide new credentials.
if (!HasLogin)
{
state.Value = APIState.Offline;
Thread.Sleep(50);
continue;
}
if (queue.Count == 0)
{
log.Add(@"Queueing a ping request");
Queue(new GetUserRequest());
}
Debug.Assert(HasLogin);
break;
// Ensure that we are in an online state. If not, attempt a connect.
if (state.Value != APIState.Online)
{
attemptConnect();
case APIState.Offline:
case APIState.Connecting:
// work to restore a connection...
if (!HasLogin)
{
state.Value = APIState.Offline;
Thread.Sleep(50);
continue;
}
state.Value = APIState.Connecting;
if (localUser.IsDefault)
{
// Show a placeholder user if saved credentials are available.
// This is useful for storing local scores and showing a placeholder username after starting the game,
// until a valid connection has been established.
localUser.Value = new APIUser
{
Username = ProvidedUsername,
};
}
// save the username at this point, if the user requested for it to be.
config.SetValue(OsuSetting.Username, config.Get<bool>(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty);
if (!authentication.HasValidAccessToken)
{
LastLoginError = null;
try
{
authentication.AuthenticateWithLogin(ProvidedUsername, password);
}
catch (Exception e)
{
//todo: this fails even on network-related issues. we should probably handle those differently.
LastLoginError = e;
log.Add(@"Login failed!");
password = null;
authentication.Clear();
continue;
}
}
var userReq = new GetUserRequest();
userReq.Failure += ex =>
{
if (ex is APIException)
{
LastLoginError = ex;
log.Add("Login failed on local user retrieval!");
Logout();
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
{
log.Add(@"Login no longer valid");
Logout();
}
else
failConnectionProcess();
};
userReq.Success += u =>
{
localUser.Value = u;
// todo: save/pull from settings
localUser.Value.Status.Value = new UserStatusOnline();
failureCount = 0;
};
if (!handleRequest(userReq))
{
failConnectionProcess();
continue;
}
// getting user's friends is considered part of the connection process.
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => failConnectionProcess();
friendsReq.Success += res =>
{
friends.AddRange(res);
//we're connected!
state.Value = APIState.Online;
};
if (!handleRequest(friendsReq))
{
failConnectionProcess();
continue;
}
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
// before actually going online.
while (State.Value > APIState.Offline && State.Value < APIState.Online)
Thread.Sleep(500);
break;
if (state.Value != APIState.Online)
continue;
}
// hard bail if we can't get a valid access token.
@ -238,31 +146,132 @@ namespace osu.Game.Online.API
continue;
}
while (true)
{
APIRequest req;
lock (queue)
{
if (queue.Count == 0) break;
req = queue.Dequeue();
}
handleRequest(req);
}
processQueuedRequests();
Thread.Sleep(50);
}
}
void failConnectionProcess()
/// <summary>
/// Dequeue from the queue and run each request synchronously until the queue is empty.
/// </summary>
private void processQueuedRequests()
{
while (true)
{
// if something went wrong during the connection process, we want to reset the state (but only if still connecting).
if (State.Value == APIState.Connecting)
state.Value = APIState.Failing;
APIRequest req;
lock (queue)
{
if (queue.Count == 0) return;
req = queue.Dequeue();
}
handleRequest(req);
}
}
/// <summary>
/// From a non-connected state, perform a full connection flow, obtaining OAuth tokens and populating the local user and friends.
/// </summary>
/// <remarks>
/// This method takes control of <see cref="state"/> and transitions from <see cref="APIState.Connecting"/> to either
/// - <see cref="APIState.Online"/> (successful connection)
/// - <see cref="APIState.Failing"/> (failed connection but retrying)
/// - <see cref="APIState.Offline"/> (failed and can't retry, clear credentials and require user interaction)
/// </remarks>
/// <returns>Whether the connection attempt was successful.</returns>
private void attemptConnect()
{
state.Value = APIState.Connecting;
if (localUser.IsDefault)
{
// Show a placeholder user if saved credentials are available.
// This is useful for storing local scores and showing a placeholder username after starting the game,
// until a valid connection has been established.
setLocalUser(new APIUser
{
Username = ProvidedUsername,
});
}
// save the username at this point, if the user requested for it to be.
config.SetValue(OsuSetting.Username, config.Get<bool>(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty);
if (!authentication.HasValidAccessToken)
{
LastLoginError = null;
try
{
authentication.AuthenticateWithLogin(ProvidedUsername, password);
}
catch (Exception e)
{
//todo: this fails even on network-related issues. we should probably handle those differently.
LastLoginError = e;
log.Add($@"Login failed for username {ProvidedUsername} ({LastLoginError.Message})!");
Logout();
return;
}
}
var userReq = new GetUserRequest();
userReq.Failure += ex =>
{
if (ex is APIException)
{
LastLoginError = ex;
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!");
Logout();
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
{
log.Add(@"Login no longer valid");
Logout();
}
else
{
state.Value = APIState.Failing;
}
};
userReq.Success += user =>
{
// todo: save/pull from settings
user.Status.Value = new UserStatusOnline();
setLocalUser(user);
// we're connected!
state.Value = APIState.Online;
failureCount = 0;
};
if (!handleRequest(userReq))
{
state.Value = APIState.Failing;
return;
}
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res => friends.AddRange(res);
if (!handleRequest(friendsReq))
{
state.Value = APIState.Failing;
return;
}
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
// before actually going online.
while (State.Value == APIState.Connecting && !cancellationToken.IsCancellationRequested)
Thread.Sleep(500);
}
public void Perform(APIRequest request)
{
try
@ -338,8 +347,7 @@ namespace osu.Game.Online.API
if (req.CompletionState != APIRequestCompletionState.Completed)
return false;
// we could still be in initialisation, at which point we don't want to say we're Online yet.
if (IsLoggedIn) state.Value = APIState.Online;
// Reset failure count if this request succeeded.
failureCount = 0;
return true;
}
@ -453,7 +461,7 @@ namespace osu.Game.Online.API
// Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present
Schedule(() =>
{
localUser.Value = createGuestUser();
setLocalUser(createGuestUser());
friends.Clear();
});
@ -463,6 +471,8 @@ namespace osu.Game.Online.API
private static APIUser createGuestUser() => new GuestUser();
private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false);
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

View File

@ -13,19 +13,16 @@ namespace osu.Game.Online.API
{
/// <summary>
/// The local user.
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindable<APIUser> LocalUser { get; }
/// <summary>
/// The user's friends.
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindableList<APIUser> Friends { get; }
/// <summary>
/// The current user's activity.
/// This is not thread-safe and should be scheduled locally if consumed from a drawable component.
/// </summary>
IBindable<UserActivity> Activity { get; }

View File

@ -1138,6 +1138,13 @@ namespace osu.Game
mouseDisableButtons.Value = !mouseDisableButtons.Value;
return true;
case GlobalAction.ToggleProfile:
if (userProfile.State.Value == Visibility.Visible)
userProfile.Hide();
else
ShowUser(API.LocalUser.Value);
return true;
case GlobalAction.RandomSkin:
// Don't allow random skin selection while in the skin editor.
// This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path.

View File

@ -4,7 +4,6 @@
#nullable disable
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Game.Graphics.Containers.Markdown;
@ -12,16 +11,8 @@ namespace osu.Game.Overlays.Comments
{
public class CommentMarkdownContainer : OsuMarkdownContainer
{
public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer();
protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock);
private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer
{
// Don't render image in comment for now
protected override void AddImage(LinkInline linkInline) { }
}
private class CommentMarkdownHeading : OsuMarkdownHeading
{
public CommentMarkdownHeading(HeadingBlock headingBlock)

View File

@ -1,8 +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.
#nullable disable
using System;
using System.Linq;
using osu.Framework.Bindables;
@ -17,10 +15,12 @@ namespace osu.Game.Overlays.Music
{
public class Playlist : OsuRearrangeableListContainer<Live<BeatmapSetInfo>>
{
public Action<Live<BeatmapSetInfo>> RequestSelection;
public Action<Live<BeatmapSetInfo>>? RequestSelection;
public readonly Bindable<Live<BeatmapSetInfo>> SelectedSet = new Bindable<Live<BeatmapSetInfo>>();
private FilterCriteria currentCriteria = new FilterCriteria();
public new MarginPadding Padding
{
get => base.Padding;
@ -31,26 +31,22 @@ namespace osu.Game.Overlays.Music
{
var items = (SearchContainer<RearrangeableListItem<Live<BeatmapSetInfo>>>)ListContainer;
string[] currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
string[]? currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
foreach (var item in items.OfType<PlaylistItem>())
{
if (currentCollectionHashes == null)
item.InSelectedCollection = true;
else
{
item.InSelectedCollection = item.Model.Value.Beatmaps.Select(b => b.MD5Hash)
.Any(currentCollectionHashes.Contains);
}
item.InSelectedCollection = currentCollectionHashes == null || item.Model.Value.Beatmaps.Select(b => b.MD5Hash).Any(currentCollectionHashes.Contains);
}
items.SearchTerm = criteria.SearchText;
currentCriteria = criteria;
}
public Live<BeatmapSetInfo> FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter);
public Live<BeatmapSetInfo>? FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter);
protected override OsuRearrangeableListItem<Live<BeatmapSetInfo>> CreateOsuDrawable(Live<BeatmapSetInfo> item) => new PlaylistItem(item)
{
InSelectedCollection = currentCriteria.Collection?.PerformRead(c => item.Value.Beatmaps.Select(b => b.MD5Hash).Any(c.BeatmapMD5Hashes.Contains)) != false,
SelectedSet = { BindTarget = SelectedSet },
RequestSelection = set => RequestSelection?.Invoke(set)
};

View File

@ -26,8 +26,6 @@ namespace osu.Game.Overlays.Music
private const float transition_duration = 600;
private const float playlist_height = 510;
public IBindableList<Live<BeatmapSetInfo>> BeatmapSets => beatmapSets;
private readonly BindableList<Live<BeatmapSetInfo>> beatmapSets = new BindableList<Live<BeatmapSetInfo>>();
private readonly Bindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
@ -104,9 +102,7 @@ namespace osu.Game.Overlays.Music
{
base.LoadComplete();
// tests might bind externally, in which case we don't want to involve realm.
if (beatmapSets.Count == 0)
beatmapSubscription = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>().Where(s => !s.DeletePending), beatmapsChanged);
beatmapSubscription = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>().Where(s => !s.DeletePending), beatmapsChanged);
list.Items.BindTo(beatmapSets);
beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true);

View File

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override double ScoreMultiplier => 0.5;
public override bool ValidForMultiplayer => false;
public override bool ValidForMultiplayerAsFreeMod => false;

View File

@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => FontAwesome.Solid.Hammer;
public override double ScoreMultiplier => 1.0;
public override double ScoreMultiplier => 0.5;
public override bool RequiresConfiguration => true;

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "RX";
public override IconUsage? Icon => OsuIcon.ModRelax;
public override ModType Type => ModType.Automation;
public override double ScoreMultiplier => 1;
public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) };
}
}

View File

@ -14,7 +14,7 @@ namespace osu.Game.Tests
[STAThread]
public static int Main(string[] args)
{
using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu", new HostOptions { BindIPC = true, }))
using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu-development", new HostOptions { BindIPC = true, }))
{
host.Run(new OsuTestBrowser());
return 0;