1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 10:18:22 +08:00

Merge branch 'ppy:master' into master

This commit is contained in:
Nostril 2022-03-10 00:52:56 -08:00 committed by GitHub
commit fc2cd78fa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 4276 additions and 885 deletions

View File

@ -50,7 +50,7 @@ Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed.
- When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/).
- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/).
- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/).
- When running on Linux, please have a system-wide FFmpeg installation available to support video decoding.
### Downloading the source code
@ -72,7 +72,7 @@ git pull
Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing).
- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln.` This will allow access to template run configurations.
- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will allow access to template run configurations.
You can also build and run *osu!* from the command-line with a single command:

View File

@ -51,11 +51,11 @@
<Reference Include="Java.Interop" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.211.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.223.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.304.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.308.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. -->
<PackageReference Include="Realm" Version="10.9.0" />
<PackageReference Include="Realm" Version="10.10.0" />
</ItemGroup>
</Project>

View File

@ -10,6 +10,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
@ -108,10 +109,7 @@ namespace osu.Desktop
presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty);
// update ruleset
int onlineID = ruleset.Value.OnlineID;
bool isLegacyRuleset = onlineID >= 0 && onlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID;
presence.Assets.SmallImageKey = isLegacyRuleset ? $"mode_{onlineID}" : "mode_custom";
presence.Assets.SmallImageKey = ruleset.Value.IsLegacyRuleset() ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom";
presence.Assets.SmallImageText = ruleset.Value.Name;
client.SetPresence(presence);

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
@ -96,6 +97,8 @@ namespace osu.Desktop
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
Debug.Assert(OperatingSystem.IsWindows());
return new SquirrelUpdateManager();
default:

View File

@ -3,6 +3,7 @@
using System;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using osu.Desktop.LegacyIpc;
@ -12,6 +13,7 @@ using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.IPC;
using osu.Game.Tournament;
using Squirrel;
namespace osu.Desktop
{
@ -24,6 +26,10 @@ namespace osu.Desktop
[STAThread]
public static void Main(string[] args)
{
// run Squirrel first, as the app may exit after these run
if (OperatingSystem.IsWindows())
setupSquirrel();
// Back up the cwd before DesktopGameHost changes it
string cwd = Environment.CurrentDirectory;
@ -104,6 +110,23 @@ namespace osu.Desktop
}
}
[SupportedOSPlatform("windows")]
private static void setupSquirrel()
{
SquirrelAwareApp.HandleEvents(onInitialInstall: (version, tools) =>
{
tools.CreateShortcutForThisExe();
tools.CreateUninstallerRegistryEntry();
}, onAppUninstall: (version, tools) =>
{
tools.RemoveShortcutForThisExe();
tools.RemoveUninstallerRegistryEntry();
}, onEveryRun: (version, tools, firstRun) =>
{
tools.SetProcessAppUserModelId();
});
}
private static int allowableExceptions = DebugUtils.IsDebugBuild ? 0 : 1;
/// <summary>

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -16,10 +17,11 @@ using osu.Game.Overlays.Notifications;
using osuTK;
using osuTK.Graphics;
using Squirrel;
using LogLevel = Splat.LogLevel;
using Squirrel.SimpleSplat;
namespace osu.Desktop.Updater
{
[SupportedOSPlatform("windows")]
public class SquirrelUpdateManager : osu.Game.Updater.UpdateManager
{
private UpdateManager updateManager;
@ -34,12 +36,14 @@ namespace osu.Desktop.Updater
/// </summary>
private bool updatePending;
private readonly SquirrelLogger squirrelLogger = new SquirrelLogger();
[BackgroundDependencyLoader]
private void load(NotificationOverlay notification)
{
notificationOverlay = notification;
Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger));
SquirrelLocator.CurrentMutable.Register(() => squirrelLogger, typeof(ILogger));
}
protected override async Task<bool> PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false);
@ -49,9 +53,11 @@ namespace osu.Desktop.Updater
// should we schedule a retry on completion of this check?
bool scheduleRecheck = true;
const string github_token = null; // TODO: populate.
try
{
updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true).ConfigureAwait(false);
updateManager ??= new GithubUpdateManager(@"https://github.com/ppy/osu", false, github_token, @"osulazer");
var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false);
@ -201,11 +207,11 @@ namespace osu.Desktop.Updater
}
}
private class SquirrelLogger : Splat.ILogger, IDisposable
private class SquirrelLogger : ILogger, IDisposable
{
public LogLevel Level { get; set; } = LogLevel.Info;
public Squirrel.SimpleSplat.LogLevel Level { get; set; } = Squirrel.SimpleSplat.LogLevel.Info;
public void Write(string message, LogLevel logLevel)
public void Write(string message, Squirrel.SimpleSplat.LogLevel logLevel)
{
if (logLevel < Level)
return;

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<asmv1:assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv1="urn:schemas-microsoft-com:asm.v1" xmlns:asmv2="urn:schemas-microsoft-com:asm.v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="1.0.0.0" name="osu!" />
<SquirrelAwareVersion xmlns="urn:schema-squirrel-com:asm.v1">1</SquirrelAwareVersion>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
@ -17,4 +18,4 @@
<dpiAware>true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
</asmv1:assembly>
</asmv1:assembly>

View File

@ -24,10 +24,10 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Clowd.Squirrel" Version="2.8.28-pre" />
<PackageReference Include="Microsoft.NETCore.Targets" Version="5.0.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="6.0.0" />
<PackageReference Include="ppy.squirrel.windows" Version="1.9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.14">
<PrivateAssets>all</PrivateAssets>

View File

@ -7,5 +7,6 @@ namespace osu.Game.Rulesets.Catch.Scoring
{
public class CatchScoreProcessor : ScoreProcessor
{
protected override double ClassicScoreMultiplier => 28;
}
}

View File

@ -258,6 +258,7 @@ namespace osu.Game.Rulesets.Mania
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new ManiaModMuted(),
new ModAdaptiveSpeed()
};
default:
@ -394,6 +395,7 @@ namespace osu.Game.Rulesets.Mania
{
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
{
new AverageHitError(score.HitEvents),
new UnstableRate(score.HitEvents)
}), true)
}

View File

@ -45,10 +45,5 @@ namespace osu.Game.Rulesets.Mania
}
};
}
private class TimeSlider : OsuSliderBar<double>
{
public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
}
}
}

View File

@ -10,5 +10,7 @@ namespace osu.Game.Rulesets.Mania.Scoring
protected override double DefaultAccuracyPortion => 0.99;
protected override double DefaultComboPortion => 0.01;
protected override double ClassicScoreMultiplier => 16;
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
using osuTK.Input;
@ -72,7 +73,11 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
EditorClock.Seek(slider.StartTime);
EditorBeatmap.SelectedHitObjects.Add(slider);
});
AddStep("change beat divisor", () => beatDivisor.Value = 3);
AddStep("change beat divisor", () =>
{
beatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.TRIPLETS;
beatDivisor.Value = 3;
});
convertToStream();

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.1.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -1,8 +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.
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
@ -25,13 +28,14 @@ namespace osu.Game.Rulesets.Osu.Mods
public override string Description => "It never gets boring!";
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE / 2;
/// <summary>
/// Number of previous hitobjects to be shifted together when another object is being moved.
/// </summary>
private const int preceding_hitobjects_to_shift = 10;
private Random rng;
private Random? rng;
public void ApplyToBeatmap(IBeatmap beatmap)
{
@ -44,28 +48,79 @@ namespace osu.Game.Rulesets.Osu.Mods
rng = new Random((int)Seed.Value);
RandomObjectInfo previous = null;
var randomObjects = randomiseObjects(hitObjects);
applyRandomisation(hitObjects, randomObjects);
}
/// <summary>
/// Randomise the position of each hit object and return a list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.
/// </summary>
/// <param name="hitObjects">A list of <see cref="OsuHitObject"/>s to have their positions randomised.</param>
/// <returns>A list of <see cref="RandomObjectInfo"/>s describing how each hit object should be placed.</returns>
private List<RandomObjectInfo> randomiseObjects(IEnumerable<OsuHitObject> hitObjects)
{
Debug.Assert(rng != null, $"{nameof(ApplyToBeatmap)} was not called before randomising objects");
var randomObjects = new List<RandomObjectInfo>();
RandomObjectInfo? previous = null;
float rateOfChangeMultiplier = 0;
for (int i = 0; i < hitObjects.Count; i++)
foreach (OsuHitObject hitObject in hitObjects)
{
var hitObject = hitObjects[i];
var current = new RandomObjectInfo(hitObject);
randomObjects.Add(current);
// rateOfChangeMultiplier only changes every 5 iterations in a combo
// to prevent shaky-line-shaped streams
if (hitObject.IndexInCurrentCombo % 5 == 0)
rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
if (previous == null)
{
current.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.X / 2);
current.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
}
else
{
current.DistanceFromPrevious = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal);
// The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
// is proportional to the distance between the last and the current hit object
// to allow jumps and prevent too sharp turns during streams.
// Allow maximum jump angle when jump distance is more than half of playfield diagonal length
current.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, current.DistanceFromPrevious / (playfield_diagonal * 0.5f));
}
previous = current;
}
return randomObjects;
}
/// <summary>
/// Reposition the hit objects according to the information in <paramref name="randomObjects"/>.
/// </summary>
/// <param name="hitObjects">The hit objects to be repositioned.</param>
/// <param name="randomObjects">A list of <see cref="RandomObjectInfo"/> describing how each hit object should be placed.</param>
private void applyRandomisation(IReadOnlyList<OsuHitObject> hitObjects, IReadOnlyList<RandomObjectInfo> randomObjects)
{
RandomObjectInfo? previous = null;
for (int i = 0; i < hitObjects.Count; i++)
{
var hitObject = hitObjects[i];
var current = randomObjects[i];
if (hitObject is Spinner)
{
previous = null;
continue;
}
applyRandomisation(rateOfChangeMultiplier, previous, current);
computeRandomisedPosition(current, previous, i > 1 ? randomObjects[i - 2] : null);
// Move hit objects back into the playfield if they are outside of it
Vector2 shift = Vector2.Zero;
@ -102,44 +157,34 @@ namespace osu.Game.Rulesets.Osu.Mods
}
/// <summary>
/// Returns the final position of the hit object
/// Compute the randomised position of a hit object while attempting to keep it inside the playfield.
/// </summary>
/// <returns>Final position of the hit object</returns>
private void applyRandomisation(float rateOfChangeMultiplier, RandomObjectInfo previous, RandomObjectInfo current)
/// <param name="current">The <see cref="RandomObjectInfo"/> representing the hit object to have the randomised position computed for.</param>
/// <param name="previous">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the current one.</param>
/// <param name="beforePrevious">The <see cref="RandomObjectInfo"/> representing the hit object immediately preceding the <paramref name="previous"/> one.</param>
private void computeRandomisedPosition(RandomObjectInfo current, RandomObjectInfo? previous, RandomObjectInfo? beforePrevious)
{
if (previous == null)
float previousAbsoluteAngle = 0f;
if (previous != null)
{
var playfieldSize = OsuPlayfield.BASE_SIZE;
current.AngleRad = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI);
current.PositionRandomised = new Vector2((float)rng.NextDouble() * playfieldSize.X, (float)rng.NextDouble() * playfieldSize.Y);
return;
Vector2 earliestPosition = beforePrevious?.HitObject.EndPosition ?? playfield_centre;
Vector2 relativePosition = previous.HitObject.Position - earliestPosition;
previousAbsoluteAngle = (float)Math.Atan2(relativePosition.Y, relativePosition.X);
}
float distanceToPrev = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal);
// The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
// is proportional to the distance between the last and the current hit object
// to allow jumps and prevent too sharp turns during streams.
// Allow maximum jump angle when jump distance is more than half of playfield diagonal length
double randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * Math.Min(1f, distanceToPrev / (playfield_diagonal * 0.5f));
current.AngleRad = (float)randomAngleRad + previous.AngleRad;
if (current.AngleRad < 0)
current.AngleRad += 2 * (float)Math.PI;
float absoluteAngle = previousAbsoluteAngle + current.RelativeAngle;
var posRelativeToPrev = new Vector2(
distanceToPrev * (float)Math.Cos(current.AngleRad),
distanceToPrev * (float)Math.Sin(current.AngleRad)
current.DistanceFromPrevious * (float)Math.Cos(absoluteAngle),
current.DistanceFromPrevious * (float)Math.Sin(absoluteAngle)
);
posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(previous.EndPositionRandomised, posRelativeToPrev);
Vector2 lastEndPosition = previous?.EndPositionRandomised ?? playfield_centre;
current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastEndPosition, posRelativeToPrev);
current.PositionRandomised = previous.EndPositionRandomised + posRelativeToPrev;
current.PositionRandomised = lastEndPosition + posRelativeToPrev;
}
/// <summary>
@ -287,7 +332,25 @@ namespace osu.Game.Rulesets.Osu.Mods
private class RandomObjectInfo
{
public float AngleRad { get; set; }
/// <summary>
/// The jump angle from the previous hit object to this one, relative to the previous hit object's jump angle.
/// </summary>
/// <remarks>
/// <see cref="RelativeAngle"/> of the first hit object in a beatmap represents the absolute angle from playfield center to the object.
/// </remarks>
/// <example>
/// If <see cref="RelativeAngle"/> is 0, the player's cursor doesn't need to change its direction of movement when passing
/// the previous object to reach this one.
/// </example>
public float RelativeAngle { get; set; }
/// <summary>
/// The jump distance from the previous hit object to this one.
/// </summary>
/// <remarks>
/// <see cref="DistanceFromPrevious"/> of the first hit object in a beatmap is relative to the playfield center.
/// </remarks>
public float DistanceFromPrevious { get; set; }
public Vector2 PositionOriginal { get; }
public Vector2 PositionRandomised { get; set; }
@ -295,11 +358,13 @@ namespace osu.Game.Rulesets.Osu.Mods
public Vector2 EndPositionOriginal { get; }
public Vector2 EndPositionRandomised { get; set; }
public OsuHitObject HitObject { get; }
public RandomObjectInfo(OsuHitObject hitObject)
{
PositionRandomised = PositionOriginal = hitObject.Position;
EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition;
AngleRad = 0;
HitObject = hitObject;
}
}
}

View File

@ -195,6 +195,7 @@ namespace osu.Game.Rulesets.Osu
new OsuModMuted(),
new OsuModNoScope(),
new OsuModAimAssist(),
new ModAdaptiveSpeed()
};
case ModType.System:
@ -314,6 +315,7 @@ namespace osu.Game.Rulesets.Osu
{
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
{
new AverageHitError(timedHitEvents),
new UnstableRate(timedHitEvents)
}), true)
}

View File

@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Scoring
{
public class OsuScoreProcessor : ScoreProcessor
{
protected override double ClassicScoreMultiplier => 36;
protected override HitEvent CreateHitEvent(JudgementResult result)
=> base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);

View File

@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Mods
{
get
{
string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N1}";
string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N2}";
return string.Join(", ", new[]
{

View File

@ -10,5 +10,7 @@ namespace osu.Game.Rulesets.Taiko.Scoring
protected override double DefaultAccuracyPortion => 0.75;
protected override double DefaultComboPortion => 0.25;
protected override double ClassicScoreMultiplier => 22;
}
}

View File

@ -149,6 +149,7 @@ namespace osu.Game.Rulesets.Taiko
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new TaikoModMuted(),
new ModAdaptiveSpeed()
};
default:
@ -235,6 +236,7 @@ namespace osu.Game.Rulesets.Taiko
{
new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[]
{
new AverageHitError(timedHitEvents),
new UnstableRate(timedHitEvents)
}), true)
}

View File

@ -79,7 +79,7 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="System.Formats.Asn1">
<Version>5.0.0</Version>
</PackageReference>

View File

@ -48,7 +48,7 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="Moq" Version="4.17.2" />
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath)\Xamarin\iOS\Xamarin.iOS.CSharp.targets" />
</Project>

View File

@ -1,23 +1,129 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Rulesets;
using osu.Game.Tests.Resources;
using Realms;
#nullable enable
namespace osu.Game.Tests.Database
{
[TestFixture]
public class RealmSubscriptionRegistrationTests : RealmTest
{
[Test]
public void TestSubscriptionCollectionAndPropertyChanges()
{
int collectionChanges = 0;
int propertyChanges = 0;
ChangeSet? lastChanges = null;
RunTestWithRealm((realm, _) =>
{
var registration = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>(), onChanged);
realm.Run(r => r.Refresh());
realm.Write(r => r.Add(TestResources.CreateTestBeatmapSetInfo()));
realm.Run(r => r.Refresh());
Assert.That(collectionChanges, Is.EqualTo(1));
Assert.That(propertyChanges, Is.EqualTo(0));
Assert.That(lastChanges?.InsertedIndices, Has.One.Items);
Assert.That(lastChanges?.ModifiedIndices, Is.Empty);
Assert.That(lastChanges?.NewModifiedIndices, Is.Empty);
realm.Write(r => r.All<BeatmapSetInfo>().First().Beatmaps.First().CountdownOffset = 5);
realm.Run(r => r.Refresh());
Assert.That(collectionChanges, Is.EqualTo(1));
Assert.That(propertyChanges, Is.EqualTo(1));
Assert.That(lastChanges?.InsertedIndices, Is.Empty);
Assert.That(lastChanges?.ModifiedIndices, Has.One.Items);
Assert.That(lastChanges?.NewModifiedIndices, Has.One.Items);
registration.Dispose();
});
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error)
{
lastChanges = changes;
if (changes == null)
return;
if (changes.HasCollectionChanges())
{
Interlocked.Increment(ref collectionChanges);
}
else
{
Interlocked.Increment(ref propertyChanges);
}
}
}
[Test]
public void TestSubscriptionWithAsyncWrite()
{
ChangeSet? lastChanges = null;
RunTestWithRealm((realm, _) =>
{
var registration = realm.RegisterForNotifications(r => r.All<BeatmapSetInfo>(), onChanged);
realm.Run(r => r.Refresh());
// Without forcing the write onto its own thread, realm will internally run the operation synchronously, which can cause a deadlock with `WaitSafely`.
Task.Run(async () =>
{
await realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo()));
}).WaitSafely();
realm.Run(r => r.Refresh());
Assert.That(lastChanges?.InsertedIndices, Has.One.Items);
registration.Dispose();
});
void onChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes, Exception error) => lastChanges = changes;
}
[Test]
public void TestPropertyChangedSubscription()
{
RunTestWithRealm((realm, _) =>
{
bool? receivedValue = null;
realm.Write(r => r.Add(TestResources.CreateTestBeatmapSetInfo()));
using (realm.SubscribeToPropertyChanged(r => r.All<BeatmapSetInfo>().First(), setInfo => setInfo.Protected, val => receivedValue = val))
{
Assert.That(receivedValue, Is.False);
realm.Write(r => r.All<BeatmapSetInfo>().First().Protected = true);
realm.Run(r => r.Refresh());
Assert.That(receivedValue, Is.True);
}
});
}
[Test]
public void TestSubscriptionWithContextLoss()
{
@ -134,5 +240,41 @@ namespace osu.Game.Tests.Database
Assert.That(beatmapSetInfo, Is.Null);
});
}
[Test]
public void TestPropertyChangedSubscriptionWithContextLoss()
{
RunTestWithRealm((realm, _) =>
{
bool? receivedValue = null;
realm.Write(r => r.Add(TestResources.CreateTestBeatmapSetInfo()));
var subscription = realm.SubscribeToPropertyChanged(
r => r.All<BeatmapSetInfo>().First(),
setInfo => setInfo.Protected,
val => receivedValue = val);
Assert.That(receivedValue, Is.Not.Null);
receivedValue = null;
using (realm.BlockAllOperations())
{
}
// re-registration after context restore.
realm.Run(r => r.Refresh());
Assert.That(receivedValue, Is.Not.Null);
subscription.Dispose();
receivedValue = null;
using (realm.BlockAllOperations())
Assert.That(receivedValue, Is.Null);
realm.Run(r => r.Refresh());
Assert.That(receivedValue, Is.Null);
});
}
}
}

View File

@ -1,29 +1,23 @@
// 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.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics.Audio;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
@ -118,59 +112,6 @@ namespace osu.Game.Tests.Gameplay
AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue);
}
[TestCase(typeof(OsuModDoubleTime), 1.5)]
[TestCase(typeof(OsuModHalfTime), 0.75)]
[TestCase(typeof(ModWindUp), 1.5)]
[TestCase(typeof(ModWindDown), 0.75)]
[TestCase(typeof(OsuModDoubleTime), 2)]
[TestCase(typeof(OsuModHalfTime), 0.5)]
[TestCase(typeof(ModWindUp), 2)]
[TestCase(typeof(ModWindDown), 0.5)]
public void TestSamplePlaybackWithRateMods(Type expectedMod, double expectedRate)
{
GameplayClockContainer gameplayContainer = null;
StoryboardSampleInfo sampleInfo = null;
TestDrawableStoryboardSample sample = null;
Mod testedMod = Activator.CreateInstance(expectedMod) as Mod;
switch (testedMod)
{
case ModRateAdjust m:
m.SpeedChange.Value = expectedRate;
break;
case ModTimeRamp m:
m.FinalRate.Value = m.InitialRate.Value = expectedRate;
break;
}
AddStep("setup storyboard sample", () =>
{
Beatmap.Value = new TestCustomSkinWorkingBeatmap(new OsuRuleset().RulesetInfo, this);
SelectedMods.Value = new[] { testedMod };
var beatmapSkinSourceContainer = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin);
Add(gameplayContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
{
Child = beatmapSkinSourceContainer
});
beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(sampleInfo = new StoryboardSampleInfo("test-sample", 1, 1))
{
Clock = gameplayContainer.GameplayClock
});
});
AddStep("start", () => gameplayContainer.Start());
AddAssert("sample playback rate matches mod rates", () =>
testedMod != null && Precision.AlmostEquals(
sample.ChildrenOfType<DrawableSample>().First().AggregateFrequency.Value,
((IApplicableToRate)testedMod).ApplyToRate(sampleInfo.StartTime)));
}
[Test]
public void TestSamplePlaybackWithBeatmapHitsoundsOff()
{

View File

@ -24,6 +24,20 @@ namespace osu.Game.Tests.Online
[TestFixture]
public class TestAPIModJsonSerialization
{
[Test]
public void TestUnknownMod()
{
var apiMod = new APIMod { Acronym = "WNG" };
var deserialized = JsonConvert.DeserializeObject<APIMod>(JsonConvert.SerializeObject(apiMod));
var converted = deserialized?.ToMod(new TestRuleset());
Assert.That(converted, Is.TypeOf(typeof(UnknownMod)));
Assert.That(converted?.Type, Is.EqualTo(ModType.System));
Assert.That(converted?.Acronym, Is.EqualTo("WNG??"));
}
[Test]
public void TestAcronymIsPreserved()
{

View File

@ -0,0 +1,40 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
using NUnit.Framework;
using osu.Game.IO.Serialization;
using osu.Game.Online.Solo;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Online
{
/// <summary>
/// Basic testing to ensure our attribute-based naming is correctly working.
/// </summary>
[TestFixture]
public class TestSubmittableScoreJsonSerialization
{
[Test]
public void TestScoreSerialisationViaExtensionMethod()
{
var score = new SubmittableScore(TestResources.CreateTestScoreInfo());
string serialised = score.Serialize();
Assert.That(serialised, Contains.Substring("large_tick_hit"));
Assert.That(serialised, Contains.Substring("\"rank\": \"S\""));
}
[Test]
public void TestScoreSerialisationWithoutSettings()
{
var score = new SubmittableScore(TestResources.CreateTestScoreInfo());
string serialised = JsonConvert.SerializeObject(score);
Assert.That(serialised, Contains.Substring("large_tick_hit"));
Assert.That(serialised, Contains.Substring("\"rank\":\"S\""));
}
}
}

View File

@ -6,11 +6,15 @@ using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Rulesets.Scoring
@ -300,7 +304,26 @@ namespace osu.Game.Tests.Rulesets.Scoring
HitObjects = { new TestHitObject(result) }
});
Assert.That(scoreProcessor.GetImmediateScore(ScoringMode.Standardised, result.AffectsCombo() ? 1 : 0, statistic), Is.EqualTo(expectedScore).Within(0.5d));
Assert.That(scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, new ScoreInfo
{
Ruleset = new TestRuleset().RulesetInfo,
MaxCombo = result.AffectsCombo() ? 1 : 0,
Statistics = statistic
}), Is.EqualTo(expectedScore).Within(0.5d));
}
private class TestRuleset : Ruleset
{
public override IEnumerable<Mod> GetModsFor(ModType type) => throw new System.NotImplementedException();
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => throw new System.NotImplementedException();
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException();
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new System.NotImplementedException();
public override string Description => string.Empty;
public override string ShortName => string.Empty;
}
private class TestJudgement : Judgement

View File

@ -2,9 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
@ -20,30 +22,31 @@ namespace osu.Game.Tests.Visual.Editing
private BeatDivisorControl beatDivisorControl;
private BindableBeatDivisor bindableBeatDivisor;
private SliderBar<int> tickSliderBar;
private EquilateralTriangle tickMarkerHead;
private SliderBar<int> tickSliderBar => beatDivisorControl.ChildrenOfType<SliderBar<int>>().Single();
private EquilateralTriangle tickMarkerHead => tickSliderBar.ChildrenOfType<EquilateralTriangle>().Single();
[SetUp]
public void SetUp() => Schedule(() =>
{
Child = beatDivisorControl = new BeatDivisorControl(bindableBeatDivisor = new BindableBeatDivisor(16))
Child = new PopoverContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(90, 90)
RelativeSizeAxes = Axes.Both,
Child = beatDivisorControl = new BeatDivisorControl(bindableBeatDivisor = new BindableBeatDivisor(16))
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(90, 90)
}
};
tickSliderBar = beatDivisorControl.ChildrenOfType<SliderBar<int>>().Single();
tickMarkerHead = tickSliderBar.ChildrenOfType<EquilateralTriangle>().Single();
});
[Test]
public void TestBindableBeatDivisor()
{
AddRepeatStep("move previous", () => bindableBeatDivisor.Previous(), 4);
AddRepeatStep("move previous", () => bindableBeatDivisor.Previous(), 2);
AddAssert("divisor is 4", () => bindableBeatDivisor.Value == 4);
AddRepeatStep("move next", () => bindableBeatDivisor.Next(), 3);
AddAssert("divisor is 12", () => bindableBeatDivisor.Value == 12);
AddRepeatStep("move next", () => bindableBeatDivisor.Next(), 1);
AddAssert("divisor is 12", () => bindableBeatDivisor.Value == 8);
}
[Test]
@ -79,5 +82,115 @@ namespace osu.Game.Tests.Visual.Editing
sliderDrawQuad.Centre.Y
);
}
[Test]
public void TestBeatChevronNavigation()
{
switchBeatSnap(1);
assertBeatSnap(1);
switchBeatSnap(3);
assertBeatSnap(8);
switchBeatSnap(-1);
assertBeatSnap(4);
switchBeatSnap(-3);
assertBeatSnap(16);
}
[Test]
public void TestBeatPresetNavigation()
{
assertPreset(BeatDivisorType.Common);
switchPresets(1);
assertPreset(BeatDivisorType.Triplets);
switchPresets(1);
assertPreset(BeatDivisorType.Common);
switchPresets(-1);
assertPreset(BeatDivisorType.Triplets);
switchPresets(-1);
assertPreset(BeatDivisorType.Common);
setDivisorViaInput(3);
assertPreset(BeatDivisorType.Triplets);
setDivisorViaInput(8);
assertPreset(BeatDivisorType.Common);
setDivisorViaInput(15);
assertPreset(BeatDivisorType.Custom, 15);
switchBeatSnap(-1);
assertBeatSnap(5);
switchBeatSnap(-1);
assertBeatSnap(3);
setDivisorViaInput(5);
assertPreset(BeatDivisorType.Custom, 15);
switchPresets(1);
assertPreset(BeatDivisorType.Common);
switchPresets(-1);
assertPreset(BeatDivisorType.Triplets);
}
private void switchBeatSnap(int direction) => AddRepeatStep($"move snap {(direction > 0 ? "forward" : "backward")}", () =>
{
int chevronIndex = direction > 0 ? 1 : 0;
var chevronButton = beatDivisorControl.ChildrenOfType<BeatDivisorControl.ChevronButton>().ElementAt(chevronIndex);
InputManager.MoveMouseTo(chevronButton);
InputManager.Click(MouseButton.Left);
}, Math.Abs(direction));
private void assertBeatSnap(int expected) => AddAssert($"beat snap is {expected}",
() => bindableBeatDivisor.Value == expected);
private void switchPresets(int direction) => AddRepeatStep($"move presets {(direction > 0 ? "forward" : "backward")}", () =>
{
int chevronIndex = direction > 0 ? 3 : 2;
var chevronButton = beatDivisorControl.ChildrenOfType<BeatDivisorControl.ChevronButton>().ElementAt(chevronIndex);
InputManager.MoveMouseTo(chevronButton);
InputManager.Click(MouseButton.Left);
}, Math.Abs(direction));
private void assertPreset(BeatDivisorType type, int? maxDivisor = null)
{
AddAssert($"preset is {type}", () => bindableBeatDivisor.ValidDivisors.Value.Type == type);
if (type == BeatDivisorType.Custom)
{
Debug.Assert(maxDivisor != null);
AddAssert($"max divisor is {maxDivisor}", () => bindableBeatDivisor.ValidDivisors.Value.Presets.Max() == maxDivisor.Value);
}
}
private void setDivisorViaInput(int divisor)
{
AddStep("open divisor input popover", () =>
{
var button = beatDivisorControl.ChildrenOfType<BeatDivisorControl.DivisorDisplay>().Single();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
BeatDivisorControl.CustomDivisorPopover popover = null;
AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType<BeatDivisorControl.CustomDivisorPopover>().SingleOrDefault()) != null && popover.IsLoaded);
AddStep($"set divisor to {divisor}", () =>
{
var textBox = popover.ChildrenOfType<TextBox>().Single();
InputManager.MoveMouseTo(textBox);
InputManager.Click(MouseButton.Left);
textBox.Text = divisor.ToString();
InputManager.Key(Key.Enter);
});
AddUntilStep("wait for dismiss", () => !this.ChildrenOfType<BeatDivisorControl.CustomDivisorPopover>().Any());
}
}
}

View File

@ -4,11 +4,9 @@
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.Menus;
namespace osu.Game.Tests.Visual.Editing
{
@ -22,7 +20,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("switch between all screens at once", () =>
{
foreach (var screen in Enum.GetValues(typeof(EditorScreenMode)).Cast<EditorScreenMode>())
Editor.ChildrenOfType<EditorMenuBar>().Single().Mode.Value = screen;
Editor.Mode.Value = screen;
});
}
}

View File

@ -0,0 +1,102 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Overlays.Settings;
using osu.Game.Scoring;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Tests.Visual.Ranking;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneBeatmapOffsetControl : OsuTestScene
{
private BeatmapOffsetControl offsetControl;
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Create control", () =>
{
Child = new PlayerSettingsGroup("Some settings")
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
offsetControl = new BeatmapOffsetControl()
}
};
});
}
[Test]
public void TestTooShortToDisplay()
{
AddStep("Set short reference score", () =>
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0, 2)
};
});
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}
[Test]
public void TestCalibrationFromZero()
{
const double average_error = -4.5;
AddAssert("Offset is neutral", () => offsetControl.Current.Value == 0);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Set reference score", () =>
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error)
};
});
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error);
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}
/// <summary>
/// When a beatmap offset was already set, the calibration should take it into account.
/// </summary>
[Test]
public void TestCalibrationFromNonZero()
{
const double average_error = -4.5;
const double initial_offset = -2;
AddStep("Set offset non-neutral", () => offsetControl.Current.Value = initial_offset);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Set reference score", () =>
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error)
};
});
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value == initial_offset - average_error);
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}
}
}

View File

@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("wait for multiple judgements", () => ((FailPlayer)Player).ScoreProcessor.JudgedHits > 1);
AddAssert("total number of results == 1", () =>
{
var score = new ScoreInfo();
var score = new ScoreInfo { Ruleset = Ruleset.Value };
((FailPlayer)Player).ScoreProcessor.PopulateScore(score);

View File

@ -131,9 +131,9 @@ namespace osu.Game.Tests.Visual.Gameplay
public double GameplayClockTime => GameplayClockContainer.GameplayClock.CurrentTime;
protected override void Update()
protected override void UpdateAfterChildren()
{
base.Update();
base.UpdateAfterChildren();
if (!FirstFrameClockTime.HasValue)
{

View File

@ -94,7 +94,7 @@ namespace osu.Game.Tests.Visual.Gameplay
var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true };
storyboardContainer.Clock = decoupledClock;
storyboard = working.Storyboard.CreateDrawable(Beatmap.Value);
storyboard = working.Storyboard.CreateDrawable(SelectedMods.Value);
storyboard.Passing = false;
storyboardContainer.Add(storyboard);
@ -118,7 +118,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sb = decoder.Decode(bfr);
}
storyboard = sb.CreateDrawable(Beatmap.Value);
storyboard = sb.CreateDrawable(SelectedMods.Value);
storyboardContainer.Add(storyboard);
decoupledClock.ChangeSource(Beatmap.Value.Track);

View File

@ -1,17 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics.Audio;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Storyboards;
using osu.Game.Storyboards.Drawables;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
{
@ -19,6 +25,10 @@ namespace osu.Game.Tests.Visual.Gameplay
{
private Storyboard storyboard;
private IReadOnlyList<Mod> storyboardMods;
protected override bool HasCustomSteps => true;
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
@ -31,42 +41,107 @@ namespace osu.Game.Tests.Visual.Gameplay
backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: 0, volume: 20));
}
[SetUp]
public void SetUp() => storyboardMods = Array.Empty<Mod>();
[Test]
public void TestStoryboardSamplesStopDuringPause()
{
checkForFirstSamplePlayback();
createPlayerTest();
AddStep("player paused", () => Player.Pause());
AddAssert("player is currently paused", () => Player.GameplayClockContainer.IsPaused.Value);
AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
allStoryboardSamplesStopped();
AddStep("player resume", () => Player.Resume());
AddUntilStep("any storyboard samples playing after resume", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
waitUntilStoryboardSamplesPlay();
}
[Test]
public void TestStoryboardSamplesStopOnSkip()
{
checkForFirstSamplePlayback();
createPlayerTest();
AddStep("skip intro", () => InputManager.Key(osuTK.Input.Key.Space));
AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
skipIntro();
allStoryboardSamplesStopped();
AddUntilStep("any storyboard samples playing after skip", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
waitUntilStoryboardSamplesPlay();
}
private void checkForFirstSamplePlayback()
[TestCase(typeof(OsuModDoubleTime), 1.5)]
[TestCase(typeof(OsuModDoubleTime), 2)]
[TestCase(typeof(OsuModHalfTime), 0.75)]
[TestCase(typeof(OsuModHalfTime), 0.5)]
public void TestStoryboardSamplesPlaybackWithRateAdjustMods(Type expectedMod, double expectedRate)
{
AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null);
AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
AddStep("setup mod", () =>
{
ModRateAdjust testedMod = (ModRateAdjust)Activator.CreateInstance(expectedMod).AsNonNull();
testedMod.SpeedChange.Value = expectedRate;
storyboardMods = new[] { testedMod };
});
createPlayerTest();
skipIntro();
AddAssert("sample playback rate matches mod rates", () => allStoryboardSamples.All(sound =>
sound.ChildrenOfType<DrawableSample>().First().AggregateFrequency.Value == expectedRate));
}
[TestCase(typeof(ModWindUp), 0.5, 2)]
[TestCase(typeof(ModWindUp), 1.51, 2)]
[TestCase(typeof(ModWindDown), 2, 0.5)]
[TestCase(typeof(ModWindDown), 0.99, 0.5)]
public void TestStoryboardSamplesPlaybackWithTimeRampMods(Type expectedMod, double initialRate, double finalRate)
{
AddStep("setup mod", () =>
{
ModTimeRamp testedMod = (ModTimeRamp)Activator.CreateInstance(expectedMod).AsNonNull();
testedMod.InitialRate.Value = initialRate;
testedMod.FinalRate.Value = finalRate;
storyboardMods = new[] { testedMod };
});
createPlayerTest();
skipIntro();
ModTimeRamp gameplayMod = null;
AddUntilStep("mod speed change updated", () =>
{
gameplayMod = Player.GameplayState.Mods.OfType<ModTimeRamp>().Single();
return gameplayMod.SpeedChange.Value != initialRate;
});
AddAssert("sample playback rate matches mod rates", () => allStoryboardSamples.All(sound =>
sound.ChildrenOfType<DrawableSample>().First().AggregateFrequency.Value == gameplayMod.SpeedChange.Value));
}
private void createPlayerTest()
{
CreateTest(null);
AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null);
waitUntilStoryboardSamplesPlay();
}
private void waitUntilStoryboardSamplesPlay() => AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
private void allStoryboardSamplesStopped() => AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
private void skipIntro() => AddStep("skip intro", () => InputManager.Key(Key.Space));
private IEnumerable<DrawableStoryboardSample> allStoryboardSamples => Player.ChildrenOfType<DrawableStoryboardSample>();
protected override bool AllowFail => false;
protected override TestPlayer CreatePlayer(Ruleset ruleset)
{
SelectedMods.Value = SelectedMods.Value.Concat(storyboardMods).ToArray();
return new TestPlayer(true, false);
}
protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, false);
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) =>
new ClockBackedTestWorkingBeatmap(beatmap, storyboard ?? this.storyboard, Clock, Audio);

View File

@ -0,0 +1,29 @@
// 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 NUnit.Framework;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneUnknownMod : ModTestScene
{
protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
/// <summary>
/// This test also covers the scenario of exiting Player after an unsuccessful beatmap load.
/// </summary>
[Test]
public void TestUnknownModDoesntEnterGameplay()
{
CreateModTest(new ModTestData
{
Beatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo).Beatmap,
Mod = new UnknownMod("WNG"),
PassCondition = () => Player.IsLoaded && !Player.LoadedBeatmapSuccessfully
});
}
}
}

View File

@ -46,7 +46,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
var scoreProcessor = new OsuScoreProcessor();
scoreProcessor.ApplyBeatmap(playable);
LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) { Expanded = { Value = true } }, Add);
LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(Ruleset.Value, scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray())
{
Expanded = { Value = true }
}, Add);
});
AddUntilStep("wait for load", () => leaderboard.IsLoaded);

View File

@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
scoreProcessor.ApplyBeatmap(playableBeatmap);
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, multiplayerUsers.ToArray())
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
scoreProcessor.ApplyBeatmap(playableBeatmap);
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, multiplayerUsers.ToArray())
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(Ruleset.Value, scoreProcessor, multiplayerUsers.ToArray())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -43,8 +43,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
}
[SetUp]
@ -52,8 +50,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
AvailabilityTracker.SelectedItem.BindTo(selectedItem);
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
importedSet = beatmaps.GetAllUsableBeatmapSets().First();
Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
{
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
@ -92,16 +92,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
OsuButton readyButton = null;
AddAssert("ensure ready button enabled", () =>
AddUntilStep("ensure ready button enabled", () =>
{
readyButton = button.ChildrenOfType<OsuButton>().Single();
return readyButton.Enabled.Value;
});
AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
AddAssert("ready button disabled", () => !readyButton.Enabled.Value);
AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value);
AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet));
AddAssert("ready button enabled back", () => readyButton.Enabled.Value);
AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value);
}
[Test]

View File

@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("ensure manager loaded", () => beatmaps != null);
ensureSoleilyRemoved();
createButtonWithBeatmap(createSoleily());
AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
AddUntilStep("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()));
AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineID == 241526));
@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Online
createButtonWithBeatmap(createSoleily());
AddUntilStep("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable);
ensureSoleilyRemoved();
AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
AddUntilStep("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded);
}
private void ensureSoleilyRemoved()

View File

@ -9,7 +9,6 @@ using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Chat;
using osuTK.Input;
@ -207,7 +206,28 @@ namespace osu.Game.Tests.Visual.Online
}
[Test]
public void TestUserScrollOverride()
public void TestOverrideChatScrolling()
{
fillChat();
sendMessage();
checkScrolledToBottom();
AddStep("Scroll to start", () => chatDisplay.ScrollContainer.ScrollToStart());
checkNotScrolledToBottom();
sendMessage();
checkNotScrolledToBottom();
AddStep("Scroll to bottom", () => chatDisplay.ScrollContainer.ScrollToEnd());
checkScrolledToBottom();
sendMessage();
checkScrolledToBottom();
}
[Test]
public void TestOverrideChatScrollingByUser()
{
fillChat();
@ -314,9 +334,9 @@ namespace osu.Game.Tests.Visual.Online
{
}
protected DrawableChannel DrawableChannel => InternalChildren.OfType<DrawableChannel>().First();
public DrawableChannel DrawableChannel => InternalChildren.OfType<DrawableChannel>().First();
protected UserTrackingScrollContainer ScrollContainer => (UserTrackingScrollContainer)((Container)DrawableChannel.Child).Child;
public ChannelScrollContainer ScrollContainer => (ChannelScrollContainer)((Container)DrawableChannel.Child).Child;
public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child;

View File

@ -33,16 +33,25 @@ namespace osu.Game.Tests.Visual.Playlists
private TestResultsScreen resultsScreen;
private int currentScoreId;
private int lowestScoreId; // Score ID of the lowest score in the list.
private int highestScoreId; // Score ID of the highest score in the list.
private bool requestComplete;
private int totalCount;
private ScoreInfo userScore;
[SetUp]
public void Setup() => Schedule(() =>
{
currentScoreId = 1;
lowestScoreId = 1;
highestScoreId = 1;
requestComplete = false;
totalCount = 0;
userScore = TestResources.CreateTestScoreInfo();
userScore.TotalScore = 0;
userScore.Statistics = new Dictionary<HitResult, int>();
bindHandler();
// beatmap is required to be an actual beatmap so the scores can get their scores correctly calculated for standardised scoring.
@ -53,15 +62,7 @@ namespace osu.Game.Tests.Visual.Playlists
[Test]
public void TestShowWithUserScore()
{
ScoreInfo userScore = null;
AddStep("bind user score info handler", () =>
{
userScore = TestResources.CreateTestScoreInfo();
userScore.OnlineID = currentScoreId++;
bindHandler(userScore: userScore);
});
AddStep("bind user score info handler", () => bindHandler(userScore: userScore));
createResults(() => userScore);
@ -81,15 +82,7 @@ namespace osu.Game.Tests.Visual.Playlists
[Test]
public void TestShowUserScoreWithDelay()
{
ScoreInfo userScore = null;
AddStep("bind user score info handler", () =>
{
userScore = TestResources.CreateTestScoreInfo();
userScore.OnlineID = currentScoreId++;
bindHandler(true, userScore);
});
AddStep("bind user score info handler", () => bindHandler(true, userScore));
createResults(() => userScore);
@ -124,7 +117,7 @@ namespace osu.Game.Tests.Visual.Playlists
AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible);
waitForDisplay();
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() == beforePanelCount + scores_per_result);
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() >= beforePanelCount + scores_per_result);
AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden);
}
}
@ -132,15 +125,7 @@ namespace osu.Game.Tests.Visual.Playlists
[Test]
public void TestFetchWhenScrolledToTheLeft()
{
ScoreInfo userScore = null;
AddStep("bind user score info handler", () =>
{
userScore = TestResources.CreateTestScoreInfo();
userScore.OnlineID = currentScoreId++;
bindHandler(userScore: userScore);
});
AddStep("bind user score info handler", () => bindHandler(userScore: userScore));
createResults(() => userScore);
@ -156,7 +141,7 @@ namespace osu.Game.Tests.Visual.Playlists
AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible);
waitForDisplay();
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() == beforePanelCount + scores_per_result);
AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType<ScorePanel>().Count() >= beforePanelCount + scores_per_result);
AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden);
}
}
@ -245,16 +230,13 @@ namespace osu.Game.Tests.Visual.Playlists
{
var multiplayerUserScore = new MultiplayerScore
{
ID = (int)(userScore.OnlineID > 0 ? userScore.OnlineID : currentScoreId++),
ID = highestScoreId,
Accuracy = userScore.Accuracy,
EndedAt = userScore.Date,
Passed = userScore.Passed,
Rank = userScore.Rank,
Position = real_user_position,
MaxCombo = userScore.MaxCombo,
TotalScore = userScore.TotalScore,
User = userScore.User,
Statistics = userScore.Statistics,
ScoresAround = new MultiplayerScoresAround
{
Higher = new MultiplayerScores(),
@ -268,38 +250,32 @@ namespace osu.Game.Tests.Visual.Playlists
{
multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore
{
ID = currentScoreId++,
ID = --highestScoreId,
Accuracy = userScore.Accuracy,
EndedAt = userScore.Date,
Passed = true,
Rank = userScore.Rank,
MaxCombo = userScore.MaxCombo,
TotalScore = userScore.TotalScore - i,
User = new APIUser
{
Id = 2,
Username = $"peppy{i}",
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
},
Statistics = userScore.Statistics
});
multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore
{
ID = currentScoreId++,
ID = ++lowestScoreId,
Accuracy = userScore.Accuracy,
EndedAt = userScore.Date,
Passed = true,
Rank = userScore.Rank,
MaxCombo = userScore.MaxCombo,
TotalScore = userScore.TotalScore + i,
User = new APIUser
{
Id = 2,
Username = $"peppy{i}",
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
},
Statistics = userScore.Statistics
});
totalCount += 2;
@ -315,33 +291,23 @@ namespace osu.Game.Tests.Visual.Playlists
{
var result = new IndexedMultiplayerScores();
long startTotalScore = req.Cursor?.Properties["total_score"].ToObject<long>() ?? 1000000;
string sort = req.IndexParams?.Properties["sort"].ToObject<string>() ?? "score_desc";
for (int i = 1; i <= scores_per_result; i++)
{
result.Scores.Add(new MultiplayerScore
{
ID = currentScoreId++,
ID = sort == "score_asc" ? --highestScoreId : ++lowestScoreId,
Accuracy = 1,
EndedAt = DateTimeOffset.Now,
Passed = true,
Rank = ScoreRank.X,
MaxCombo = 1000,
TotalScore = startTotalScore + (sort == "score_asc" ? i : -i),
User = new APIUser
{
Id = 2,
Username = $"peppy{i}",
CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
},
Statistics = new Dictionary<HitResult, int>
{
{ HitResult.Miss, 1 },
{ HitResult.Meh, 50 },
{ HitResult.Good, 100 },
{ HitResult.Great, 300 }
}
});
totalCount++;
@ -367,7 +333,7 @@ namespace osu.Game.Tests.Visual.Playlists
{
Properties = new Dictionary<string, JToken>
{
{ "sort", JToken.FromObject(scores.Scores[^1].TotalScore > scores.Scores[^2].TotalScore ? "score_asc" : "score_desc") }
{ "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_asc" : "score_desc") }
}
};
}

View File

@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Ranking.Statistics;
@ -17,22 +18,33 @@ namespace osu.Game.Tests.Visual.Ranking
{
public class TestSceneHitEventTimingDistributionGraph : OsuTestScene
{
private HitEventTimingDistributionGraph graph;
private static readonly HitObject placeholder_object = new HitCircle();
[Test]
public void TestManyDistributedEvents()
{
createTest(CreateDistributedHitEvents());
AddStep("add adjustment", () => graph.UpdateOffset(10));
}
[Test]
public void TestManyDistributedEventsOffset()
{
createTest(CreateDistributedHitEvents(-3.5));
}
[Test]
public void TestAroundCentre()
{
createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
}
[Test]
public void TestZeroTimeOffset()
{
createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList());
createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList());
}
[Test]
@ -47,9 +59,9 @@ namespace osu.Game.Tests.Visual.Ranking
createTest(Enumerable.Range(0, 100).Select(i =>
{
if (i % 2 == 0)
return new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null);
return new HitEvent(0, HitResult.Perfect, placeholder_object, placeholder_object, null);
return new HitEvent(30, HitResult.Miss, new HitCircle(), new HitCircle(), null);
return new HitEvent(30, HitResult.Miss, placeholder_object, placeholder_object, null);
}).ToList());
}
@ -62,7 +74,7 @@ namespace osu.Game.Tests.Visual.Ranking
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#333")
},
new HitEventTimingDistributionGraph(events)
graph = new HitEventTimingDistributionGraph(events)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -71,16 +83,16 @@ namespace osu.Game.Tests.Visual.Ranking
};
});
public static List<HitEvent> CreateDistributedHitEvents()
public static List<HitEvent> CreateDistributedHitEvents(double centre = 0, double range = 25)
{
var hitEvents = new List<HitEvent>();
for (int i = 0; i < 50; i++)
for (int i = 0; i < range * 2; i++)
{
int count = (int)(Math.Pow(25 - Math.Abs(i - 25), 2));
int count = (int)(Math.Pow(range - Math.Abs(i - range), 2)) / 10;
for (int j = 0; j < count; j++)
hitEvents.Add(new HitEvent(i - 25, HitResult.Perfect, new HitCircle(), new HitCircle(), null));
hitEvents.Add(new HitEvent(centre + i - range, HitResult.Perfect, placeholder_object, placeholder_object, null));
}
return hitEvents;

View File

@ -17,10 +17,13 @@ using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking;
using osu.Game.Screens.Ranking.Expanded.Statistics;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Tests.Resources;
using osuTK;
@ -256,6 +259,23 @@ namespace osu.Game.Tests.Visual.Ranking
AddAssert("download button is enabled", () => screen.ChildrenOfType<DownloadButton>().Last().Enabled.Value);
}
[Test]
public void TestRulesetWithNoPerformanceCalculator()
{
var ruleset = new RulesetWithNoPerformanceCalculator();
var score = TestResources.CreateTestScoreInfo(ruleset.RulesetInfo);
AddStep("load results", () => Child = new TestResultsContainer(createResultsScreen(score)));
AddUntilStep("wait for load", () => this.ChildrenOfType<ScorePanelList>().Single().AllPanelsVisible);
AddAssert("PP displayed as 0", () =>
{
var performance = this.ChildrenOfType<PerformanceStatistic>().Single();
var counter = performance.ChildrenOfType<StatisticCounter>().Single();
return counter.Current.Value == 0;
});
}
private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? TestResources.CreateTestScoreInfo());
private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(TestResources.CreateTestScoreInfo());
@ -367,5 +387,10 @@ namespace osu.Game.Tests.Visual.Ranking
RetryOverlay = InternalChildren.OfType<HotkeyRetryOverlay>().SingleOrDefault();
}
}
private class RulesetWithNoPerformanceCalculator : OsuRuleset
{
public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => null;
}
}
}

View File

@ -2,12 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Models;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking;
using osu.Game.Tests.Resources;
@ -208,13 +210,19 @@ namespace osu.Game.Tests.Visual.Ranking
public void TestKeyboardNavigation()
{
var lowestScore = TestResources.CreateTestScoreInfo();
lowestScore.MaxCombo = 100;
lowestScore.OnlineID = 3;
lowestScore.TotalScore = 0;
lowestScore.Statistics = new Dictionary<HitResult, int>();
var middleScore = TestResources.CreateTestScoreInfo();
middleScore.MaxCombo = 200;
middleScore.OnlineID = 2;
middleScore.TotalScore = 0;
middleScore.Statistics = new Dictionary<HitResult, int>();
var highestScore = TestResources.CreateTestScoreInfo();
highestScore.MaxCombo = 300;
highestScore.OnlineID = 1;
highestScore.TotalScore = 0;
highestScore.Statistics = new Dictionary<HitResult, int>();
createListStep(() => new ScorePanelList());

View File

@ -119,7 +119,8 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep(@"Network failure", () => leaderboard.SetErrorState(LeaderboardState.NetworkFailure));
AddStep(@"No supporter", () => leaderboard.SetErrorState(LeaderboardState.NotSupporter));
AddStep(@"Not logged in", () => leaderboard.SetErrorState(LeaderboardState.NotLoggedIn));
AddStep(@"Unavailable", () => leaderboard.SetErrorState(LeaderboardState.Unavailable));
AddStep(@"Ruleset unavailable", () => leaderboard.SetErrorState(LeaderboardState.RulesetUnavailable));
AddStep(@"Beatmap unavailable", () => leaderboard.SetErrorState(LeaderboardState.BeatmapUnavailable));
AddStep(@"None selected", () => leaderboard.SetErrorState(LeaderboardState.NoneSelected));
}

View File

@ -284,14 +284,13 @@ namespace osu.Game.Tests.Visual.SongSelect
public void TestDummy()
{
createSongSelect();
AddAssert("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap);
AddUntilStep("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap);
AddUntilStep("dummy shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap == defaultBeatmap);
addManyTestMaps();
AddWaitStep("wait for select", 3);
AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
}
[Test]
@ -299,9 +298,8 @@ namespace osu.Game.Tests.Visual.SongSelect
{
createSongSelect();
addManyTestMaps();
AddWaitStep("wait for add", 3);
AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap);
AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist));
AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title));
@ -571,6 +569,8 @@ namespace osu.Game.Tests.Visual.SongSelect
createSongSelect();
AddUntilStep("wait for selection", () => !Beatmap.IsDefault);
AddStep("press ctrl+enter", () =>
{
InputManager.PressKey(Key.ControlLeft);
@ -605,6 +605,8 @@ namespace osu.Game.Tests.Visual.SongSelect
addRulesetImportStep(0);
createSongSelect();
AddUntilStep("wait for selection", () => !Beatmap.IsDefault);
DrawableCarouselBeatmapSet set = null;
AddStep("Find the DrawableCarouselBeatmapSet", () =>
{
@ -844,6 +846,8 @@ namespace osu.Game.Tests.Visual.SongSelect
createSongSelect();
AddUntilStep("wait for selection", () => !Beatmap.IsDefault);
AddStep("present score", () =>
{
// this beatmap change should be overridden by the present.

View File

@ -124,7 +124,7 @@ namespace osu.Game.Tests.Visual.SongSelect
});
AddUntilStep("Became present", () => topLocalRank.IsPresent);
AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.B);
AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.B);
AddStep("Add higher score for current user", () =>
{
@ -137,7 +137,7 @@ namespace osu.Game.Tests.Visual.SongSelect
scoreManager.Import(testScoreInfo2);
});
AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.S);
AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.S);
}
}
}

View File

@ -0,0 +1,40 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestSceneDifficultyMultiplierDisplay : OsuTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
[Test]
public void TestDifficultyMultiplierDisplay()
{
DifficultyMultiplierDisplay multiplierDisplay = null;
AddStep("create content", () => Child = multiplierDisplay = new DifficultyMultiplierDisplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
AddStep("set multiplier below 1", () => multiplierDisplay.Current.Value = 0.5);
AddStep("set multiplier to 1", () => multiplierDisplay.Current.Value = 1);
AddStep("set multiplier above 1", () => multiplierDisplay.Current.Value = 1.5);
AddSliderStep("set multiplier", 0, 2, 1d, multiplier =>
{
if (multiplierDisplay != null)
multiplierDisplay.Current.Value = multiplier;
});
}
}
}

View File

@ -0,0 +1,187 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Taiko;
using osuTK.Input;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestSceneModColumn : OsuManualInputManagerTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
[TestCase(ModType.DifficultyReduction)]
[TestCase(ModType.DifficultyIncrease)]
[TestCase(ModType.Conversion)]
[TestCase(ModType.Automation)]
[TestCase(ModType.Fun)]
public void TestBasic(ModType modType)
{
AddStep("create content", () => Child = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(30),
Child = new ModColumn(modType, false)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
});
AddStep("change ruleset to osu!", () => Ruleset.Value = new OsuRuleset().RulesetInfo);
AddStep("change ruleset to taiko", () => Ruleset.Value = new TaikoRuleset().RulesetInfo);
AddStep("change ruleset to catch", () => Ruleset.Value = new CatchRuleset().RulesetInfo);
AddStep("change ruleset to mania", () => Ruleset.Value = new ManiaRuleset().RulesetInfo);
}
[Test]
public void TestMultiSelection()
{
ModColumn column = null;
AddStep("create content", () => Child = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(30),
Child = column = new ModColumn(ModType.DifficultyIncrease, true)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
});
AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded);
clickToggle();
AddUntilStep("all panels selected", () => this.ChildrenOfType<ModPanel>().All(panel => panel.Active.Value));
clickToggle();
AddUntilStep("all panels deselected", () => this.ChildrenOfType<ModPanel>().All(panel => !panel.Active.Value));
AddStep("manually activate all panels", () => this.ChildrenOfType<ModPanel>().ForEach(panel => panel.Active.Value = true));
AddUntilStep("checkbox selected", () => this.ChildrenOfType<OsuCheckbox>().Single().Current.Value);
AddStep("deselect first panel", () => this.ChildrenOfType<ModPanel>().First().Active.Value = false);
AddUntilStep("checkbox not selected", () => !this.ChildrenOfType<OsuCheckbox>().Single().Current.Value);
void clickToggle() => AddStep("click toggle", () =>
{
var checkbox = this.ChildrenOfType<OsuCheckbox>().Single();
InputManager.MoveMouseTo(checkbox);
InputManager.Click(MouseButton.Left);
});
}
[Test]
public void TestFiltering()
{
TestModColumn column = null;
AddStep("create content", () => Child = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(30),
Child = column = new TestModColumn(ModType.Fun, true)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
});
AddStep("set filter", () => column.Filter = mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase));
AddUntilStep("two panels visible", () => column.ChildrenOfType<ModPanel>().Count(panel => !panel.Filtered.Value) == 2);
clickToggle();
AddUntilStep("wait for animation", () => !column.SelectionAnimationRunning);
AddAssert("only visible items selected", () => column.ChildrenOfType<ModPanel>().Where(panel => panel.Active.Value).All(panel => !panel.Filtered.Value));
AddStep("unset filter", () => column.Filter = null);
AddUntilStep("all panels visible", () => column.ChildrenOfType<ModPanel>().All(panel => !panel.Filtered.Value));
AddAssert("checkbox not selected", () => !column.ChildrenOfType<OsuCheckbox>().Single().Current.Value);
AddStep("set filter", () => column.Filter = mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase));
AddUntilStep("two panels visible", () => column.ChildrenOfType<ModPanel>().Count(panel => !panel.Filtered.Value) == 2);
AddAssert("checkbox selected", () => column.ChildrenOfType<OsuCheckbox>().Single().Current.Value);
AddStep("filter out everything", () => column.Filter = _ => false);
AddUntilStep("no panels visible", () => column.ChildrenOfType<ModPanel>().All(panel => panel.Filtered.Value));
AddUntilStep("checkbox hidden", () => !column.ChildrenOfType<OsuCheckbox>().Single().IsPresent);
AddStep("inset filter", () => column.Filter = null);
AddUntilStep("all panels visible", () => column.ChildrenOfType<ModPanel>().All(panel => !panel.Filtered.Value));
AddUntilStep("checkbox visible", () => column.ChildrenOfType<OsuCheckbox>().Single().IsPresent);
void clickToggle() => AddStep("click toggle", () =>
{
var checkbox = this.ChildrenOfType<OsuCheckbox>().Single();
InputManager.MoveMouseTo(checkbox);
InputManager.Click(MouseButton.Left);
});
}
[Test]
public void TestKeyboardSelection()
{
ModColumn column = null;
AddStep("create content", () => Child = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(30),
Child = column = new ModColumn(ModType.DifficultyReduction, true, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P })
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
});
AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded);
AddStep("press W", () => InputManager.Key(Key.W));
AddAssert("NF panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
AddStep("press W again", () => InputManager.Key(Key.W));
AddAssert("NF panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
AddStep("set filter to NF", () => column.Filter = mod => mod.Acronym == "NF");
AddStep("press W", () => InputManager.Key(Key.W));
AddAssert("NF panel selected", () => this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
AddStep("press W again", () => InputManager.Key(Key.W));
AddAssert("NF panel deselected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
AddStep("filter out everything", () => column.Filter = _ => false);
AddStep("press W", () => InputManager.Key(Key.W));
AddAssert("NF panel not selected", () => !this.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.Acronym == "NF").Active.Value);
AddStep("clear filter", () => column.Filter = null);
}
private class TestModColumn : ModColumn
{
public new bool SelectionAnimationRunning => base.SelectionAnimationRunning;
public TestModColumn(ModType modType, bool allowBulkSelection)
: base(modType, allowBulkSelection)
{
}
}
}
}

View File

@ -0,0 +1,45 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public class TestScenePopupScreenTitle : OsuTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
[Test]
public void TestPopupScreenTitle()
{
AddStep("create content", () =>
{
Child = new PopupScreenTitle
{
Title = "Popup Screen Title",
Description = string.Join(" ", Enumerable.Repeat("This is a description.", 20)),
Close = () => { }
};
});
}
[Test]
public void TestDisabledExit()
{
AddStep("create content", () =>
{
Child = new PopupScreenTitle
{
Title = "Popup Screen Title",
Description = "This is a description."
};
});
}
}
}

View File

@ -8,7 +8,7 @@
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.1.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="Moq" Version="4.17.2" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>

View File

@ -0,0 +1,50 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Tests.Visual;
using osu.Game.Tournament.Components;
using osu.Game.Tournament.Models;
namespace osu.Game.Tournament.Tests.Components
{
[TestFixture]
public class TestSceneSongBar : OsuTestScene
{
[Cached]
private readonly LadderInfo ladder = new LadderInfo();
[Test]
public void TestSongBar()
{
SongBar songBar = null;
AddStep("create bar", () => Child = songBar = new SongBar
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
AddUntilStep("wait for loaded", () => songBar.IsLoaded);
AddStep("set beatmap", () =>
{
var beatmap = CreateAPIBeatmap(Ruleset.Value);
beatmap.CircleSize = 3.4f;
beatmap.ApproachRate = 6.8f;
beatmap.OverallDifficulty = 5.5f;
beatmap.StarRating = 4.56f;
beatmap.Length = 123456;
beatmap.BPM = 133;
songBar.Beatmap = beatmap;
});
AddStep("set mods to HR", () => songBar.Mods = LegacyMods.HardRock);
AddStep("set mods to DT", () => songBar.Mods = LegacyMods.DoubleTime);
AddStep("unset mods", () => songBar.Mods = LegacyMods.None);
}
}
}

View File

@ -186,7 +186,7 @@ namespace osu.Game.Tournament.Components
Children = new Drawable[]
{
new DiffPiece(stats),
new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.#}{srExtra}"))
new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.##}{srExtra}"))
}
},
new FillFlowContainer

View File

@ -24,9 +24,6 @@ namespace osu.Game.Tournament.Components
private readonly string mod;
private const float horizontal_padding = 10;
private const float vertical_padding = 10;
public const float HEIGHT = 50;
private readonly Bindable<TournamentMatch> currentMatch = new Bindable<TournamentMatch>();

View File

@ -40,6 +40,8 @@ namespace osu.Game.Beatmaps
[Backlink(nameof(ScoreInfo.BeatmapInfo))]
public IQueryable<ScoreInfo> Scores { get; } = null!;
public BeatmapUserSettings UserSettings { get; set; } = null!;
public BeatmapInfo(RulesetInfo? ruleset = null, BeatmapDifficulty? difficulty = null, BeatmapMetadata? metadata = null)
{
ID = Guid.NewGuid();
@ -51,6 +53,7 @@ namespace osu.Game.Beatmaps
};
Difficulty = difficulty ?? new BeatmapDifficulty();
Metadata = metadata ?? new BeatmapMetadata();
UserSettings = new BeatmapUserSettings();
}
[UsedImplicitly]

View File

@ -0,0 +1,19 @@
// 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 Realms;
namespace osu.Game.Beatmaps
{
/// <summary>
/// User settings overrides that are attached to a beatmap.
/// </summary>
public class BeatmapUserSettings : EmbeddedObject
{
/// <summary>
/// An audio offset that can be used for timing adjustments.
/// </summary>
public double Offset { get; set; }
}
}

View File

@ -164,7 +164,7 @@ namespace osu.Game.Beatmaps.ControlPoints
int closestDivisor = 0;
double closestTime = double.MaxValue;
foreach (int divisor in BindableBeatDivisor.VALID_DIVISORS)
foreach (int divisor in BindableBeatDivisor.PREDEFINED_DIVISORS)
{
double distanceFromSnap = Math.Abs(time - getClosestSnappedTime(timingPoint, time, divisor));

View File

@ -9,6 +9,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Rulesets;
@ -83,7 +84,7 @@ namespace osu.Game.Beatmaps
requestedUserId = api.LocalUser.Value.Id;
// only query API for built-in rulesets
rulesets.AvailableRulesets.Where(ruleset => ruleset.OnlineID >= 0 && ruleset.OnlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID).ForEach(rulesetInfo =>
rulesets.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo =>
{
var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo);

View File

@ -140,7 +140,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f);
SetDefault(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f);
SetDefault(OsuSetting.UIHoldActivationDelay, 200.0, 0.0, 500.0, 50.0);
SetDefault(OsuSetting.IntroSequence, IntroSequence.Triangles);
@ -240,9 +240,9 @@ namespace osu.Game.Configuration
};
}
public Func<Guid, string> LookupSkinName { private get; set; }
public Func<Guid, string> LookupSkinName { private get; set; } = _ => @"unknown";
public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; }
public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; } = _ => @"unknown";
}
// IMPORTANT: These are used in user configuration files.
@ -270,7 +270,13 @@ namespace osu.Game.Configuration
MouseDisableButtons,
MouseDisableWheel,
ConfineMouseMode,
/// <summary>
/// Globally applied audio offset.
/// This is added to the audio track's current time. Higher values will cause gameplay to occur earlier, relative to the audio track.
/// </summary>
AudioOffset,
VolumeInactive,
MenuMusic,
MenuVoice,

View File

@ -3,25 +3,28 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Input.Bindings;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Configuration;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Skinning;
using osu.Game.Stores;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Skinning;
using osu.Game.Stores;
using Realms;
using Realms.Exceptions;
@ -53,8 +56,9 @@ namespace osu.Game.Database
/// 11 2021-11-22 Use ShortName instead of RulesetID for ruleset key bindings.
/// 12 2021-11-24 Add Status to RealmBeatmapSet.
/// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields).
/// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo.
/// </summary>
private const int schema_version = 13;
private const int schema_version = 14;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -85,6 +89,14 @@ namespace osu.Game.Database
private static readonly GlobalStatistic<int> total_subscriptions = GlobalStatistics.Get<int>(@"Realm", @"Subscriptions");
private static readonly GlobalStatistic<int> total_reads_update = GlobalStatistics.Get<int>(@"Realm", @"Reads (Update)");
private static readonly GlobalStatistic<int> total_reads_async = GlobalStatistics.Get<int>(@"Realm", @"Reads (Async)");
private static readonly GlobalStatistic<int> total_writes_update = GlobalStatistics.Get<int>(@"Realm", @"Writes (Update)");
private static readonly GlobalStatistic<int> total_writes_async = GlobalStatistics.Get<int>(@"Realm", @"Writes (Async)");
private readonly object realmLock = new object();
private Realm? updateRealm;
@ -93,6 +105,8 @@ namespace osu.Game.Database
public Realm Realm => ensureUpdateRealm();
private const string realm_extension = @".realm";
private Realm ensureUpdateRealm()
{
if (isSendingNotificationResetEvents)
@ -137,11 +151,18 @@ namespace osu.Game.Database
Filename = filename;
const string realm_extension = @".realm";
if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal))
Filename += realm_extension;
string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}";
// Attempt to recover a newer database version if available.
if (storage.Exists(newerVersionFilename))
{
Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database);
attemptRecoverFromFile(newerVersionFilename);
}
try
{
// This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date.
@ -149,15 +170,78 @@ namespace osu.Game.Database
}
catch (Exception e)
{
Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made.");
// See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022
// This is the best way we can detect a schema version downgrade.
if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal))
{
Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data.");
CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}");
storage.Delete(Filename);
// If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about.
if (!storage.Exists(newerVersionFilename))
CreateBackup(newerVersionFilename);
storage.Delete(Filename);
}
else
{
Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made.");
CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}");
storage.Delete(Filename);
}
cleanupPendingDeletions();
}
}
private void attemptRecoverFromFile(string recoveryFilename)
{
Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database);
// First check the user hasn't started to use the database that is in place..
try
{
using (var realm = Realm.GetInstance(getConfiguration()))
{
if (realm.All<ScoreInfo>().Any())
{
Logger.Log(@"Recovery aborted as the existing database has scores set already.", LoggingTarget.Database);
Logger.Log(@"To perform recovery, delete client.realm while osu! is not running.", LoggingTarget.Database);
return;
}
}
}
catch
{
// Even if reading the in place database fails, still attempt to recover.
}
// Then check that the database we are about to attempt recovery can actually be recovered on this version..
try
{
using (Realm.GetInstance(getConfiguration(recoveryFilename)))
{
// Don't need to do anything, just check that opening the realm works correctly.
}
}
catch
{
Logger.Log(@"Recovery aborted as the newer version could not be loaded by this osu! version.", LoggingTarget.Database);
return;
}
// For extra safety, also store the temporarily-used database which we are about to replace.
CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}");
storage.Delete(Filename);
using (var inputStream = storage.GetStream(recoveryFilename))
using (var outputStream = storage.GetStream(Filename, FileAccess.Write, FileMode.Create))
inputStream.CopyTo(outputStream);
storage.Delete(recoveryFilename);
Logger.Log(@"Recovery complete!", LoggingTarget.Database);
}
private void cleanupPendingDeletions()
{
using (var realm = getRealmInstance())
@ -213,8 +297,12 @@ namespace osu.Game.Database
public T Run<T>(Func<Realm, T> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_reads_update.Value++;
return action(Realm);
}
total_reads_async.Value++;
using (var realm = getRealmInstance())
return action(realm);
}
@ -226,9 +314,13 @@ namespace osu.Game.Database
public void Run(Action<Realm> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_reads_update.Value++;
action(Realm);
}
else
{
total_reads_async.Value++;
using (var realm = getRealmInstance())
action(realm);
}
@ -241,14 +333,30 @@ namespace osu.Game.Database
public void Write(Action<Realm> action)
{
if (ThreadSafety.IsUpdateThread)
{
total_writes_update.Value++;
Realm.Write(action);
}
else
{
total_writes_async.Value++;
using (var realm = getRealmInstance())
realm.Write(action);
}
}
/// <summary>
/// Write changes to realm asynchronously, guaranteeing order of execution.
/// </summary>
/// <param name="action">The work to run.</param>
public async Task WriteAsync(Action<Realm> action)
{
total_writes_async.Value++;
using (var realm = getRealmInstance())
await realm.WriteAsync(action);
}
/// <summary>
/// Subscribe to a realm collection and begin watching for asynchronous changes.
/// </summary>
@ -284,6 +392,66 @@ namespace osu.Game.Database
}
}
/// <summary>
/// Subscribe to the property of a realm object to watch for changes.
/// </summary>
/// <remarks>
/// On subscribing, unless the <paramref name="modelAccessor"/> does not match an object, an initial invocation of <paramref name="onChanged"/> will occur immediately.
/// Further invocations will occur when the value changes, but may also fire on a realm recycle with no actual value change.
/// </remarks>
/// <param name="modelAccessor">A function to retrieve the relevant model from realm.</param>
/// <param name="propertyLookup">A function to traverse to the relevant property from the model.</param>
/// <param name="onChanged">A function to be invoked when a change of value occurs.</param>
/// <typeparam name="TModel">The type of the model.</typeparam>
/// <typeparam name="TProperty">The type of the property to be watched.</typeparam>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="IDisposable.Dispose"/>.
/// </returns>
public IDisposable SubscribeToPropertyChanged<TModel, TProperty>(Func<Realm, TModel?> modelAccessor, Expression<Func<TModel, TProperty>> propertyLookup, Action<TProperty> onChanged)
where TModel : RealmObjectBase
{
return RegisterCustomSubscription(r =>
{
string propertyName = getMemberName(propertyLookup);
var model = Run(modelAccessor);
var propLookupCompiled = propertyLookup.Compile();
if (model == null)
return null;
model.PropertyChanged += onPropertyChanged;
// Update initial value immediately.
onChanged(propLookupCompiled(model));
return new InvokeOnDisposal(() => model.PropertyChanged -= onPropertyChanged);
void onPropertyChanged(object sender, PropertyChangedEventArgs args)
{
if (args.PropertyName == propertyName)
onChanged(propLookupCompiled(model));
}
});
static string getMemberName(Expression<Func<TModel, TProperty>> expression)
{
if (!(expression is LambdaExpression lambda))
throw new ArgumentException("Outermost expression must be a lambda expression", nameof(expression));
if (!(lambda.Body is MemberExpression memberExpression))
throw new ArgumentException("Lambda body must be a member access expression", nameof(expression));
// TODO: nested access can be supported, with more iteration here
// (need to iteratively soft-cast `memberExpression.Expression` into `MemberExpression`s until `lambda.Parameters[0]` is hit)
if (memberExpression.Expression != lambda.Parameters[0])
throw new ArgumentException("Nested access expressions are not supported", nameof(expression));
return memberExpression.Member.Name;
}
}
/// <summary>
/// Run work on realm that will be run every time the update thread realm instance gets recycled.
/// </summary>
@ -380,7 +548,7 @@ namespace osu.Game.Database
}
}
private RealmConfiguration getConfiguration()
private RealmConfiguration getConfiguration(string? filename = null)
{
// This is currently the only usage of temporary files at the osu! side.
// If we use the temporary folder in more situations in the future, this should be moved to a higher level (helper method or OsuGameBase).
@ -388,7 +556,7 @@ namespace osu.Game.Database
if (!Directory.Exists(tempPathLocation))
Directory.CreateDirectory(tempPathLocation);
return new RealmConfiguration(storage.GetFullPath(Filename, true))
return new RealmConfiguration(storage.GetFullPath(filename ?? Filename, true))
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
@ -531,6 +699,11 @@ namespace osu.Game.Database
}
break;
case 14:
foreach (var beatmap in migration.NewRealm.All<BeatmapInfo>())
beatmap.UserSettings = new BeatmapUserSettings();
break;
}
}

View File

@ -4,6 +4,8 @@
using System;
using Realms;
#nullable enable
namespace osu.Game.Database
{
public static class RealmExtensions
@ -22,5 +24,14 @@ namespace osu.Game.Database
transaction.Commit();
return result;
}
/// <summary>
/// Whether the provided change set has changes to the top level collection.
/// </summary>
/// <remarks>
/// Realm subscriptions fire on both collection and property changes (including *all* nested properties).
/// Quite often we only care about changes at a collection level. This can be used to guard and early-return when no such changes are in a callback.
/// </remarks>
public static bool HasCollectionChanges(this ChangeSet changes) => changes.InsertedIndices.Length > 0 || changes.DeletedIndices.Length > 0 || changes.Moves.Length > 0;
}
}

View File

@ -38,6 +38,7 @@ namespace osu.Game.Database
c.CreateMap<BeatmapInfo, BeatmapInfo>()
.ForMember(s => s.Ruleset, cc => cc.Ignore())
.ForMember(s => s.Metadata, cc => cc.Ignore())
.ForMember(s => s.UserSettings, cc => cc.Ignore())
.ForMember(s => s.Difficulty, cc => cc.Ignore())
.ForMember(s => s.BeatmapSet, cc => cc.Ignore())
.AfterMap((s, d) =>
@ -154,6 +155,7 @@ namespace osu.Game.Database
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
c.CreateMap<BeatmapMetadata, BeatmapMetadata>();
c.CreateMap<BeatmapUserSettings, BeatmapUserSettings>();
c.CreateMap<BeatmapDifficulty, BeatmapDifficulty>();
c.CreateMap<RulesetInfo, RulesetInfo>();
c.CreateMap<ScoreInfo, ScoreInfo>();

View File

@ -72,6 +72,11 @@ namespace osu.Game.Extensions
return result;
}
/// <summary>
/// Check whether this <see cref="IRulesetInfo"/>'s online ID is within the range that defines it as a legacy ruleset (ie. either osu!, osu!taiko, osu!catch or osu!mania).
/// </summary>
public static bool IsLegacyRuleset(this IRulesetInfo ruleset) => ruleset.OnlineID >= 0 && ruleset.OnlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID;
/// <summary>
/// Check whether the online ID of two <see cref="IBeatmapSetInfo"/>s match.
/// </summary>

View File

@ -3,12 +3,15 @@
#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.Timing;
using osu.Game.Beatmaps;
using osu.Game.Overlays;
using osu.Game.Rulesets.Mods;
using osu.Game.Storyboards.Drawables;
namespace osu.Game.Graphics.Backgrounds
@ -20,6 +23,9 @@ namespace osu.Game.Graphics.Backgrounds
[Resolved(CanBeNull = true)]
private MusicController? musicController { get; set; }
[Resolved]
private IBindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
public BeatmapBackgroundWithStoryboard(WorkingBeatmap beatmap, string fallbackTextureName = "Backgrounds/bg1")
: base(beatmap, fallbackTextureName)
{
@ -39,7 +45,7 @@ namespace osu.Game.Graphics.Backgrounds
{
RelativeSizeAxes = Axes.Both,
Volume = { Value = 0 },
Child = new DrawableStoryboard(Beatmap.Storyboard) { Clock = storyboardClock }
Child = new DrawableStoryboard(Beatmap.Storyboard, mods.Value) { Clock = storyboardClock }
}, AddInternal);
}

View File

@ -30,12 +30,12 @@ namespace osu.Game.Graphics.Containers
public Bindable<double> Progress = new BindableDouble();
private Bindable<float> holdActivationDelay;
private Bindable<double> holdActivationDelay;
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
holdActivationDelay = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay);
holdActivationDelay = config.GetBindable<double>(OsuSetting.UIHoldActivationDelay);
}
protected void BeginConfirm()

View File

@ -5,6 +5,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Screens;
using osu.Game.Configuration;
using osu.Game.Screens;
@ -38,24 +39,24 @@ namespace osu.Game.Graphics.Containers
private BackgroundScreenStack backgroundStack;
private bool allowScaling = true;
private RectangleF? customRect;
private bool customRectIsRelativePosition;
/// <summary>
/// Whether user scaling preferences should be applied. Enabled by default.
/// Set a custom position and scale which overrides any user specification.
/// </summary>
public bool AllowScaling
/// <param name="rect">A rectangle with positional and sizing information for this container to conform to. <c>null</c> will clear the custom rect and revert to user settings.</param>
/// <param name="relativePosition">Whether the position portion of the provided rect is in relative coordinate space or not.</param>
public void SetCustomRect(RectangleF? rect, bool relativePosition = false)
{
get => allowScaling;
set
{
if (value == allowScaling)
return;
customRect = rect;
customRectIsRelativePosition = relativePosition;
allowScaling = value;
if (IsLoaded) Scheduler.AddOnce(updateSize);
}
if (IsLoaded) Scheduler.AddOnce(updateSize);
}
private const float corner_radius = 10;
/// <summary>
/// Create a new instance.
/// </summary>
@ -69,7 +70,7 @@ namespace osu.Game.Graphics.Containers
{
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both,
CornerRadius = 10,
CornerRadius = corner_radius,
Child = content = new ScalingDrawSizePreservingFillContainer(targetMode != ScalingMode.Gameplay)
};
}
@ -137,7 +138,7 @@ namespace osu.Game.Graphics.Containers
private void updateSize()
{
const float fade_time = 500;
const float duration = 500;
if (targetMode == ScalingMode.Everything)
{
@ -156,17 +157,31 @@ namespace osu.Game.Graphics.Containers
backgroundStack.Push(new ScalingBackgroundScreen());
}
backgroundStack.FadeIn(fade_time);
backgroundStack.FadeIn(duration);
}
else
backgroundStack?.FadeOut(fade_time);
backgroundStack?.FadeOut(duration);
}
bool scaling = AllowScaling && (targetMode == null || scalingMode.Value == targetMode);
RectangleF targetRect = new RectangleF(Vector2.Zero, Vector2.One);
var targetSize = scaling ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One;
var targetPosition = scaling ? new Vector2(posX.Value, posY.Value) * (Vector2.One - targetSize) : Vector2.Zero;
bool requiresMasking = (scaling && targetSize != Vector2.One)
if (customRect != null)
{
sizableContainer.RelativePositionAxes = customRectIsRelativePosition ? Axes.Both : Axes.None;
targetRect = customRect.Value;
}
else if (targetMode == null || scalingMode.Value == targetMode)
{
sizableContainer.RelativePositionAxes = Axes.Both;
Vector2 scale = new Vector2(sizeX.Value, sizeY.Value);
Vector2 pos = new Vector2(posX.Value, posY.Value) * (Vector2.One - scale);
targetRect = new RectangleF(pos, scale);
}
bool requiresMasking = targetRect.Size != Vector2.One
// For the top level scaling container, for now we apply masking if safe areas are in use.
// In the future this can likely be removed as more of the actual UI supports overflowing into the safe areas.
|| (targetMode == ScalingMode.Everything && safeAreaPadding.Value.Total != Vector2.Zero);
@ -174,8 +189,14 @@ namespace osu.Game.Graphics.Containers
if (requiresMasking)
sizableContainer.Masking = true;
sizableContainer.MoveTo(targetPosition, 500, Easing.OutQuart);
sizableContainer.ResizeTo(targetSize, 500, Easing.OutQuart).OnComplete(_ => { sizableContainer.Masking = requiresMasking; });
sizableContainer.MoveTo(targetRect.Location, duration, Easing.OutQuart);
sizableContainer.ResizeTo(targetRect.Size, duration, Easing.OutQuart);
// Of note, this will not work great in the case of nested ScalingContainers where multiple are applying corner radius.
// Masking and corner radius should likely only be applied at one point in the full game stack to fix this.
// An example of how this can occur is when the skin editor is visible and the game screen scaling is set to "Everything".
sizableContainer.TransformTo(nameof(CornerRadius), requiresMasking ? corner_radius : 0, duration, requiresMasking ? Easing.OutQuart : Easing.None)
.OnComplete(_ => { sizableContainer.Masking = requiresMasking; });
}
private class ScalingBackgroundScreen : BackgroundScreenDefault

View File

@ -25,8 +25,6 @@ namespace osu.Game.Graphics.Containers
/// </summary>
public bool UserScrolling { get; private set; }
public void CancelUserScroll() => UserScrolling = false;
public UserTrackingScrollContainer()
{
}

View File

@ -140,6 +140,7 @@ namespace osu.Game.Graphics.Cursor
// Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird)
channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * 0.75;
channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range);
channel.Volume.Value = baseFrequency;
channel.Play();
}

View File

@ -148,7 +148,7 @@ namespace osu.Game.Graphics.UserInterface
protected override void LoadComplete()
{
base.LoadComplete();
CurrentNumber.BindValueChanged(current => updateTooltipText(current.NewValue), true);
CurrentNumber.BindValueChanged(current => TooltipText = getTooltipText(current.NewValue), true);
}
protected override bool OnHover(HoverEvent e)
@ -178,7 +178,7 @@ namespace osu.Game.Graphics.UserInterface
{
base.OnUserChange(value);
playSample(value);
updateTooltipText(value);
TooltipText = getTooltipText(value);
}
private void playSample(T value)
@ -203,28 +203,22 @@ namespace osu.Game.Graphics.UserInterface
channel.Play();
}
private void updateTooltipText(T value)
private LocalisableString getTooltipText(T value)
{
if (CurrentNumber.IsInteger)
TooltipText = value.ToInt32(NumberFormatInfo.InvariantInfo).ToString("N0");
else
{
double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo);
return value.ToInt32(NumberFormatInfo.InvariantInfo).ToString("N0");
if (DisplayAsPercentage)
{
TooltipText = floatValue.ToString("0%");
}
else
{
decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits);
double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo);
// Find the number of significant digits (we could have less than 5 after normalize())
int significantDigits = FormatUtils.FindPrecision(decimalPrecision);
if (DisplayAsPercentage)
return floatValue.ToString("0%");
TooltipText = floatValue.ToString($"N{significantDigits}");
}
}
decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits);
// Find the number of significant digits (we could have less than 5 after normalize())
int significantDigits = FormatUtils.FindPrecision(decimalPrecision);
return floatValue.ToString($"N{significantDigits}");
}
protected override void UpdateAfterChildren()

View File

@ -0,0 +1,154 @@
// 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;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
public class PopupScreenTitle : CompositeDrawable
{
public LocalisableString Title
{
set => titleSpriteText.Text = value;
}
public LocalisableString Description
{
set => descriptionText.Text = value;
}
public Action? Close
{
get => closeButton.Action;
set => closeButton.Action = value;
}
private const float corner_radius = 14;
private const float main_area_height = 70;
private readonly Container underlayContainer;
private readonly Box underlayBackground;
private readonly Container contentContainer;
private readonly Box contentBackground;
private readonly OsuSpriteText titleSpriteText;
private readonly OsuTextFlowContainer descriptionText;
private readonly IconButton closeButton;
public PopupScreenTitle()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChild = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding
{
Horizontal = 70,
Top = -corner_radius
},
Children = new Drawable[]
{
underlayContainer = new Container
{
RelativeSizeAxes = Axes.X,
Height = main_area_height + 2 * corner_radius,
CornerRadius = corner_radius,
Masking = true,
BorderThickness = 2,
Child = underlayBackground = new Box
{
RelativeSizeAxes = Axes.Both
}
},
contentContainer = new Container
{
RelativeSizeAxes = Axes.X,
Height = main_area_height + corner_radius,
CornerRadius = corner_radius,
Masking = true,
BorderThickness = 2,
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Colour = Colour4.Black.Opacity(0.1f),
Offset = new Vector2(0, 1),
Radius = 3
},
Children = new Drawable[]
{
contentBackground = new Box
{
RelativeSizeAxes = Axes.Both
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Margin = new MarginPadding { Top = corner_radius },
Padding = new MarginPadding { Horizontal = 100 },
Children = new Drawable[]
{
titleSpriteText = new OsuSpriteText
{
Font = OsuFont.TorusAlternate.With(size: 20)
},
descriptionText = new OsuTextFlowContainer(t =>
{
t.Font = OsuFont.Default.With(size: 12);
})
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}
}
},
closeButton = new IconButton
{
Icon = FontAwesome.Solid.Times,
Scale = new Vector2(0.6f),
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Margin = new MarginPadding
{
Right = 21,
Top = corner_radius
}
}
}
}
}
};
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
underlayContainer.BorderColour = ColourInfo.GradientVertical(Colour4.Black, colourProvider.Dark4);
underlayBackground.Colour = colourProvider.Dark4;
contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Dark3, colourProvider.Dark1);
contentBackground.Colour = colourProvider.Dark3;
closeButton.IconHoverColour = colourProvider.Highlight1;
}
}
}

View File

@ -0,0 +1,15 @@
// 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.Localisation;
namespace osu.Game.Graphics.UserInterface
{
/// <summary>
/// A slider bar which displays a millisecond time value.
/// </summary>
public class TimeSlider : OsuSliderBar<double>
{
public override LocalisableString TooltipText => $"{Current.Value:N0} ms";
}
}

View File

@ -35,7 +35,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
set => Component.Text = value;
}
public Container TabbableContentContainer
public CompositeDrawable TabbableContentContainer
{
set => Component.TabbableContentContainer = value;
}

View File

@ -0,0 +1,44 @@
// 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.Localisation;
namespace osu.Game.Localisation
{
public static class BeatmapOffsetControlStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.BeatmapOffsetControl";
/// <summary>
/// "Beatmap offset"
/// </summary>
public static LocalisableString BeatmapOffset => new TranslatableString(getKey(@"beatmap_offset"), @"Beatmap offset");
/// <summary>
/// "Previous play:"
/// </summary>
public static LocalisableString PreviousPlay => new TranslatableString(getKey(@"previous_play"), @"Previous play:");
/// <summary>
/// "Previous play too short to use for calibration"
/// </summary>
public static LocalisableString PreviousPlayTooShortToUseForCalibration => new TranslatableString(getKey(@"previous_play_too_short_to_use_for_calibration"), @"Previous play too short to use for calibration");
/// <summary>
/// "Calibrate using last play"
/// </summary>
public static LocalisableString CalibrateUsingLastPlay => new TranslatableString(getKey(@"calibrate_using_last_play"), @"Calibrate using last play");
/// <summary>
/// "(hit objects appear later)"
/// </summary>
public static LocalisableString HitObjectsAppearLater => new TranslatableString(getKey(@"hit_objects_appear_later"), @"(hit objects appear later)");
/// <summary>
/// "(hit objects appear earlier)"
/// </summary>
public static LocalisableString HitObjectsAppearEarlier => new TranslatableString(getKey(@"hit_objects_appear_earlier"), @"(hit objects appear earlier)");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -54,6 +54,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString Resolution => new TranslatableString(getKey(@"resolution"), @"Resolution");
/// <summary>
/// "Display"
/// </summary>
public static LocalisableString Display => new TranslatableString(getKey(@"display"), @"Display");
/// <summary>
/// "UI scaling"
/// </summary>

View File

@ -0,0 +1,49 @@
// 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.Localisation;
namespace osu.Game.Localisation
{
public static class LeaderboardStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.Leaderboard";
/// <summary>
/// "Couldn't fetch scores!"
/// </summary>
public static LocalisableString CouldntFetchScores => new TranslatableString(getKey(@"couldnt_fetch_scores"), @"Couldn't fetch scores!");
/// <summary>
/// "Please select a beatmap!"
/// </summary>
public static LocalisableString PleaseSelectABeatmap => new TranslatableString(getKey(@"please_select_a_beatmap"), @"Please select a beatmap!");
/// <summary>
/// "Leaderboards are not available for this ruleset!"
/// </summary>
public static LocalisableString LeaderboardsAreNotAvailableForThisRuleset => new TranslatableString(getKey(@"leaderboards_are_not_available_for_this_ruleset"), @"Leaderboards are not available for this ruleset!");
/// <summary>
/// "Leaderboards are not available for this beatmap!"
/// </summary>
public static LocalisableString LeaderboardsAreNotAvailableForThisBeatmap => new TranslatableString(getKey(@"leaderboards_are_not_available_for_this_beatmap"), @"Leaderboards are not available for this beatmap!");
/// <summary>
/// "No records yet!"
/// </summary>
public static LocalisableString NoRecordsYet => new TranslatableString(getKey(@"no_records_yet"), @"No records yet!");
/// <summary>
/// "Please sign in to view online leaderboards!"
/// </summary>
public static LocalisableString PleaseSignInToViewOnlineLeaderboards => new TranslatableString(getKey(@"please_sign_in_to_view_online_leaderboards"), @"Please sign in to view online leaderboards!");
/// <summary>
/// "Please invest in an osu!supporter tag to view this leaderboard!"
/// </summary>
public static LocalisableString PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard => new TranslatableString(getKey(@"please_invest_in_an_osu_supporter_tag_to_view_this_leaderboard"), @"Please invest in an osu!supporter tag to view this leaderboard!");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -8,6 +8,7 @@ using Humanizer;
using MessagePack;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
@ -51,7 +52,10 @@ namespace osu.Game.Online.API
Mod resultMod = ruleset.CreateModFromAcronym(Acronym);
if (resultMod == null)
throw new InvalidOperationException($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}.");
{
Logger.Log($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}.");
return new UnknownMod(Acronym);
}
if (Settings.Count > 0)
{

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Extensions;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
@ -8,14 +10,14 @@ namespace osu.Game.Online.API.Requests
public class GetWikiRequest : APIRequest<APIWikiPage>
{
private readonly string path;
private readonly string locale;
private readonly Language language;
public GetWikiRequest(string path, string locale = "en")
public GetWikiRequest(string path, Language language = Language.en)
{
this.path = path;
this.locale = locale;
this.language = language;
}
protected override string Target => $"wiki/{locale}/{path}";
protected override string Target => $"wiki/{language.ToCultureCode()}/{path}";
}
}

View File

@ -22,6 +22,7 @@ using osu.Game.Online.API;
using osu.Game.Online.Placeholders;
using osuTK;
using osuTK.Graphics;
using osu.Game.Localisation;
namespace osu.Game.Online.Leaderboards
{
@ -311,25 +312,28 @@ namespace osu.Game.Online.Leaderboards
switch (state)
{
case LeaderboardState.NetworkFailure:
return new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync)
return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync)
{
Action = RefetchScores
};
case LeaderboardState.NoneSelected:
return new MessagePlaceholder(@"Please select a beatmap!");
return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap);
case LeaderboardState.Unavailable:
return new MessagePlaceholder(@"Leaderboards are not available for this beatmap!");
case LeaderboardState.RulesetUnavailable:
return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset);
case LeaderboardState.BeatmapUnavailable:
return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap);
case LeaderboardState.NoScores:
return new MessagePlaceholder(@"No records yet!");
return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet);
case LeaderboardState.NotLoggedIn:
return new LoginPlaceholder(@"Please sign in to view online leaderboards!");
return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards);
case LeaderboardState.NotSupporter:
return new MessagePlaceholder(@"Please invest in an osu!supporter tag to view this leaderboard!");
return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard);
case LeaderboardState.Retrieving:
return null;

View File

@ -8,7 +8,8 @@ namespace osu.Game.Online.Leaderboards
Success,
Retrieving,
NetworkFailure,
Unavailable,
BeatmapUnavailable,
RulesetUnavailable,
NoneSelected,
NoScores,
NotLoggedIn,

View File

@ -3,6 +3,7 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Overlays;
namespace osu.Game.Online.Placeholders
@ -12,7 +13,7 @@ namespace osu.Game.Online.Placeholders
[Resolved(CanBeNull = true)]
private LoginOverlay login { get; set; }
public LoginPlaceholder(string actionMessage)
public LoginPlaceholder(LocalisableString actionMessage)
: base(actionMessage, FontAwesome.Solid.UserLock)
{
Action = () => login?.Show();

View File

@ -10,12 +10,11 @@ using osu.Game.IO.Serialization.Converters;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Utils;
namespace osu.Game.Online.Rooms
{
[JsonObject(MemberSerialization.OptIn)]
public class Room : IDeepCloneable<Room>
public class Room
{
[Cached]
[JsonProperty("id")]
@ -153,22 +152,6 @@ namespace osu.Game.Online.Rooms
Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue));
}
/// <summary>
/// Create a copy of this room without online information.
/// Should be used to create a local copy of a room for submitting in the future.
/// </summary>
public Room DeepClone()
{
var copy = new Room();
copy.CopyFrom(this);
// ID must be unset as we use this as a marker for whether this is a client-side (not-yet-created) room or not.
copy.RoomID.Value = null;
return copy;
}
public void CopyFrom(Room other)
{
RoomID.Value = other.RoomID.Value;

View File

@ -46,9 +46,6 @@ namespace osu.Game.Online.Solo
[JsonProperty("mods")]
public APIMod[] Mods { get; set; }
[JsonProperty("user")]
public APIUser User { get; set; }
[JsonProperty("statistics")]
public Dictionary<HitResult, int> Statistics { get; set; }
@ -67,7 +64,6 @@ namespace osu.Game.Online.Solo
RulesetID = score.RulesetID;
Passed = score.Passed;
Mods = score.APIMods;
User = score.User;
Statistics = score.Statistics;
}
}

View File

@ -79,7 +79,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
var beatmapInfo = new BeatmapInfo
{
MaxCombo = apiBeatmap.MaxCombo,
Status = apiBeatmap.Status
Status = apiBeatmap.Status,
MD5Hash = apiBeatmap.MD5Hash
};
scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token)

View File

@ -0,0 +1,70 @@
// 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.Graphics;
using osu.Game.Graphics.Containers;
namespace osu.Game.Overlays.Chat
{
/// <summary>
/// An <see cref="OsuScrollContainer"/> with functionality to automatically scroll whenever the maximum scrollable distance increases.
/// </summary>
public class ChannelScrollContainer : OsuScrollContainer
{
/// <summary>
/// The chat will be automatically scrolled to end if and only if
/// the distance between the current scroll position and the end of the scroll
/// is less than this value.
/// </summary>
private const float auto_scroll_leniency = 10f;
/// <summary>
/// Whether to keep this container scrolled to end on new content.
/// </summary>
/// <remarks>
/// This is specifically controlled by whether the latest scroll operation made the container scrolled to end.
/// </remarks>
private bool trackNewContent = true;
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
if (trackNewContent && !IsScrolledToEnd())
ScrollToEnd();
}
private void updateTrackState() => trackNewContent = IsScrolledToEnd(auto_scroll_leniency);
// todo: we may eventually want this encapsulated in a "OnScrollChange" event handler method provided by ScrollContainer.
// important to note that this intentionally doesn't consider OffsetScrollPosition, but could make it do so with side changes.
#region Scroll handling
protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = null)
{
base.OnUserScroll(value, animated, distanceDecay);
updateTrackState();
}
public new void ScrollIntoView(Drawable d, bool animated = true)
{
base.ScrollIntoView(d, animated);
updateTrackState();
}
public new void ScrollToStart(bool animated = true, bool allowDuringDrag = false)
{
base.ScrollToStart(animated, allowDuringDrag);
updateTrackState();
}
public new void ScrollToEnd(bool animated = true, bool allowDuringDrag = false)
{
base.ScrollToEnd(animated, allowDuringDrag);
updateTrackState();
}
#endregion
}
}

View File

@ -11,9 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat;
@ -236,52 +234,5 @@ namespace osu.Game.Overlays.Chat
};
}
}
/// <summary>
/// An <see cref="OsuScrollContainer"/> with functionality to automatically scroll whenever the maximum scrollable distance increases.
/// </summary>
private class ChannelScrollContainer : UserTrackingScrollContainer
{
/// <summary>
/// The chat will be automatically scrolled to end if and only if
/// the distance between the current scroll position and the end of the scroll
/// is less than this value.
/// </summary>
private const float auto_scroll_leniency = 10f;
private float? lastExtent;
protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default)
{
base.OnUserScroll(value, animated, distanceDecay);
lastExtent = null;
}
protected override void Update()
{
base.Update();
// If the user has scrolled to the bottom of the container, we should resume tracking new content.
if (UserScrolling && IsScrolledToEnd(auto_scroll_leniency))
CancelUserScroll();
// If the user hasn't overridden our behaviour and there has been new content added to the container, we should update our scroll position to track it.
bool requiresScrollUpdate = !UserScrolling && (lastExtent == null || Precision.AlmostBigger(ScrollableExtent, lastExtent.Value));
if (requiresScrollUpdate)
{
// Schedule required to allow FillFlow to be the correct size.
Schedule(() =>
{
if (!UserScrolling)
{
if (Current < ScrollableExtent)
ScrollToEnd();
lastExtent = ScrollableExtent;
}
});
}
}
}
}
}

View File

@ -0,0 +1,185 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Mods;
using osuTK;
namespace osu.Game.Overlays.Mods
{
public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue<double>
{
public Bindable<double> Current
{
get => current.Current;
set => current.Current = value;
}
private readonly BindableNumberWithCurrent<double> current = new BindableNumberWithCurrent<double>(1)
{
Precision = 0.01
};
private readonly Box underlayBackground;
private readonly Box contentBackground;
private readonly FillFlowContainer multiplierFlow;
private readonly MultiplierCounter multiplierCounter;
[Resolved]
private OsuColour colours { get; set; }
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
private const float height = 42;
private const float multiplier_value_area_width = 56;
private const float transition_duration = 200;
public DifficultyMultiplierDisplay()
{
Height = height;
AutoSizeAxes = Axes.X;
InternalChild = new Container
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Masking = true,
CornerRadius = ModPanel.CORNER_RADIUS,
Shear = new Vector2(ModPanel.SHEAR_X, 0),
Children = new Drawable[]
{
underlayBackground = new Box
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
RelativeSizeAxes = Axes.Y,
Width = multiplier_value_area_width + ModPanel.CORNER_RADIUS
},
new GridContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, multiplier_value_area_width)
},
Content = new[]
{
new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Masking = true,
CornerRadius = ModPanel.CORNER_RADIUS,
Children = new Drawable[]
{
contentBackground = new Box
{
RelativeSizeAxes = Axes.Both
},
new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Margin = new MarginPadding { Horizontal = 18 },
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
Text = "Difficulty Multiplier",
Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold)
}
}
},
multiplierFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
Direction = FillDirection.Horizontal,
Spacing = new Vector2(2, 0),
Children = new Drawable[]
{
multiplierCounter = new MultiplierCounter
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Current = { BindTarget = Current }
},
new SpriteIcon
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Icon = FontAwesome.Solid.Times,
Size = new Vector2(7),
Margin = new MarginPadding { Top = 1 }
}
}
}
}
}
}
}
};
}
[BackgroundDependencyLoader]
private void load()
{
contentBackground.Colour = colourProvider.Background4;
}
protected override void LoadComplete()
{
base.LoadComplete();
current.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
// required to prevent the counter initially rolling up from 0 to 1
// due to `Current.Value` having a nonstandard default value of 1.
multiplierCounter.SetCountWithoutRolling(Current.Value);
}
private void updateState()
{
if (Current.IsDefault)
{
underlayBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint);
multiplierFlow.FadeColour(Colour4.White, transition_duration, Easing.OutQuint);
}
else
{
var backgroundColour = Current.Value < 1
? colours.ForModType(ModType.DifficultyReduction)
: colours.ForModType(ModType.DifficultyIncrease);
underlayBackground.FadeColour(backgroundColour, transition_duration, Easing.OutQuint);
multiplierFlow.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint);
}
}
private class MultiplierCounter : RollingCounter<double>
{
protected override double RollingDuration => 500;
protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N2");
protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText
{
Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold)
};
}
}
}

View File

@ -0,0 +1,426 @@
// 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 Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
#nullable enable
namespace osu.Game.Overlays.Mods
{
public class ModColumn : CompositeDrawable
{
private Func<Mod, bool>? filter;
/// <summary>
/// Function determining whether each mod in the column should be displayed.
/// A return value of <see langword="true"/> means that the mod is not filtered and therefore its corresponding panel should be displayed.
/// A return value of <see langword="false"/> means that the mod is filtered out and therefore its corresponding panel should be hidden.
/// </summary>
public Func<Mod, bool>? Filter
{
get => filter;
set
{
filter = value;
updateFilter();
}
}
private readonly ModType modType;
private readonly Key[]? toggleKeys;
private readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> availableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>();
private readonly TextFlowContainer headerText;
private readonly Box headerBackground;
private readonly Container contentContainer;
private readonly Box contentBackground;
private readonly FillFlowContainer<ModPanel> panelFlow;
private readonly ToggleAllCheckbox? toggleAllCheckbox;
private Colour4 accentColour;
private Task? latestLoadTask;
internal bool ItemsLoaded => latestLoadTask == null;
private const float header_height = 42;
public ModColumn(ModType modType, bool allowBulkSelection, Key[]? toggleKeys = null)
{
this.modType = modType;
this.toggleKeys = toggleKeys;
Width = 320;
RelativeSizeAxes = Axes.Y;
Shear = new Vector2(ModPanel.SHEAR_X, 0);
CornerRadius = ModPanel.CORNER_RADIUS;
Masking = true;
Container controlContainer;
InternalChildren = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.X,
Height = header_height + ModPanel.CORNER_RADIUS,
Children = new Drawable[]
{
headerBackground = new Box
{
RelativeSizeAxes = Axes.X,
Height = header_height + ModPanel.CORNER_RADIUS
},
headerText = new OsuTextFlowContainer(t =>
{
t.Font = OsuFont.TorusAlternate.With(size: 17);
t.Shadow = false;
t.Colour = Colour4.Black;
})
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Shear = new Vector2(-ModPanel.SHEAR_X, 0),
Padding = new MarginPadding
{
Horizontal = 17,
Bottom = ModPanel.CORNER_RADIUS
}
}
}
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = header_height },
Child = contentContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = ModPanel.CORNER_RADIUS,
BorderThickness = 3,
Children = new Drawable[]
{
contentBackground = new Box
{
RelativeSizeAxes = Axes.Both
},
new GridContainer
{
RelativeSizeAxes = Axes.Both,
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension()
},
Content = new[]
{
new Drawable[]
{
controlContainer = new Container
{
RelativeSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 14 }
}
},
new Drawable[]
{
new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
ScrollbarOverlapsContent = false,
Child = panelFlow = new FillFlowContainer<ModPanel>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0, 7),
Padding = new MarginPadding(7)
}
}
}
}
}
}
}
}
};
createHeaderText();
if (allowBulkSelection)
{
controlContainer.Height = 35;
controlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.8f),
RelativeSizeAxes = Axes.X,
LabelText = "Enable All",
Shear = new Vector2(-ModPanel.SHEAR_X, 0)
});
panelFlow.Padding = new MarginPadding
{
Top = 0,
Bottom = 7,
Horizontal = 7
};
}
}
private void createHeaderText()
{
IEnumerable<string> headerTextWords = modType.Humanize(LetterCasing.Title).Split(' ');
if (headerTextWords.Count() > 1)
{
headerText.AddText($"{headerTextWords.First()} ", t => t.Font = t.Font.With(weight: FontWeight.SemiBold));
headerTextWords = headerTextWords.Skip(1);
}
headerText.AddText(string.Join(' ', headerTextWords));
}
[BackgroundDependencyLoader]
private void load(OsuGameBase game, OverlayColourProvider colourProvider, OsuColour colours)
{
availableMods.BindTo(game.AvailableMods);
headerBackground.Colour = accentColour = colours.ForModType(modType);
if (toggleAllCheckbox != null)
{
toggleAllCheckbox.AccentColour = accentColour;
toggleAllCheckbox.AccentHoverColour = accentColour.Lighten(0.3f);
}
contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3);
contentBackground.Colour = colourProvider.Background4;
}
protected override void LoadComplete()
{
base.LoadComplete();
availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods));
updateMods();
}
private CancellationTokenSource? cancellationTokenSource;
private void updateMods()
{
var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(modType) ?? Array.Empty<Mod>()).ToList();
if (newMods.SequenceEqual(panelFlow.Children.Select(p => p.Mod)))
return;
cancellationTokenSource?.Cancel();
var panels = newMods.Select(mod => new ModPanel(mod)
{
Shear = new Vector2(-ModPanel.SHEAR_X, 0)
});
Task? loadTask;
latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded =>
{
panelFlow.ChildrenEnumerable = loaded;
foreach (var panel in panelFlow)
panel.Active.BindValueChanged(_ => updateToggleState());
updateToggleState();
updateFilter();
}, (cancellationTokenSource = new CancellationTokenSource()).Token);
loadTask.ContinueWith(_ =>
{
if (loadTask == latestLoadTask)
latestLoadTask = null;
});
}
#region Bulk select / deselect
private const double initial_multiple_selection_delay = 120;
private double selectionDelay = initial_multiple_selection_delay;
private double lastSelection;
private readonly Queue<Action> pendingSelectionOperations = new Queue<Action>();
protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0;
protected override void Update()
{
base.Update();
if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay)
{
if (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
{
dequeuedAction();
// each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements).
selectionDelay = Math.Max(30, selectionDelay * 0.8f);
lastSelection = Time.Current;
}
else
{
// reset the selection delay after all animations have been completed.
// this will cause the next action to be immediately performed.
selectionDelay = initial_multiple_selection_delay;
}
}
}
private void updateToggleState()
{
if (toggleAllCheckbox != null && !SelectionAnimationRunning)
{
toggleAllCheckbox.Alpha = panelFlow.Any(panel => !panel.Filtered.Value) ? 1 : 0;
toggleAllCheckbox.Current.Value = panelFlow.Where(panel => !panel.Filtered.Value).All(panel => panel.Active.Value);
}
}
/// <summary>
/// Selects all mods.
/// </summary>
public void SelectAll()
{
pendingSelectionOperations.Clear();
foreach (var button in panelFlow.Where(b => !b.Active.Value && !b.Filtered.Value))
pendingSelectionOperations.Enqueue(() => button.Active.Value = true);
}
/// <summary>
/// Deselects all mods.
/// </summary>
public void DeselectAll()
{
pendingSelectionOperations.Clear();
foreach (var button in panelFlow.Where(b => b.Active.Value && !b.Filtered.Value))
pendingSelectionOperations.Enqueue(() => button.Active.Value = false);
}
private class ToggleAllCheckbox : OsuCheckbox
{
private Color4 accentColour;
public Color4 AccentColour
{
get => accentColour;
set
{
accentColour = value;
updateState();
}
}
private Color4 accentHoverColour;
public Color4 AccentHoverColour
{
get => accentHoverColour;
set
{
accentHoverColour = value;
updateState();
}
}
private readonly ModColumn column;
public ToggleAllCheckbox(ModColumn column)
: base(false)
{
this.column = column;
}
protected override void ApplyLabelParameters(SpriteText text)
{
base.ApplyLabelParameters(text);
text.Font = text.Font.With(weight: FontWeight.SemiBold);
}
[BackgroundDependencyLoader]
private void load()
{
updateState();
}
private void updateState()
{
Nub.AccentColour = AccentColour;
Nub.GlowingAccentColour = AccentHoverColour;
Nub.GlowColour = AccentHoverColour.Opacity(0.2f);
}
protected override void OnUserChange(bool value)
{
if (value)
column.SelectAll();
else
column.DeselectAll();
}
}
#endregion
#region Filtering support
private void updateFilter()
{
foreach (var modPanel in panelFlow)
modPanel.ApplyFilter(Filter);
updateToggleState();
}
#endregion
#region Keyboard selection support
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.ControlPressed || e.AltPressed) return false;
if (toggleKeys == null) return false;
int index = Array.IndexOf(toggleKeys, e.Key);
if (index < 0) return false;
var panel = panelFlow.ElementAtOrDefault(index);
if (panel == null || panel.Filtered.Value) return false;
panel.Active.Toggle();
return true;
}
#endregion
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@ -28,6 +29,7 @@ namespace osu.Game.Overlays.Mods
{
public Mod Mod { get; }
public BindableBool Active { get; } = new BindableBool();
public BindableBool Filtered { get; } = new BindableBool();
protected readonly Box Background;
protected readonly Container SwitchContainer;
@ -40,10 +42,10 @@ namespace osu.Game.Overlays.Mods
protected const double TRANSITION_DURATION = 150;
protected const float SHEAR_X = 0.2f;
public const float SHEAR_X = 0.2f;
public const float CORNER_RADIUS = 7;
protected const float HEIGHT = 42;
protected const float CORNER_RADIUS = 7;
protected const float IDLE_SWITCH_WIDTH = 54;
protected const float EXPANDED_SWITCH_WIDTH = 70;
@ -157,6 +159,7 @@ namespace osu.Game.Overlays.Mods
playStateChangeSamples();
UpdateState();
});
Filtered.BindValueChanged(_ => updateFilterState());
UpdateState();
FinishTransforms(true);
@ -190,7 +193,7 @@ namespace osu.Game.Overlays.Mods
mouseDown = true;
UpdateState();
return true;
return false;
}
protected override void OnMouseUp(MouseUpEvent e)
@ -235,5 +238,19 @@ namespace osu.Game.Overlays.Mods
TextBackground.FadeColour(textBackgroundColour, transitionDuration, Easing.OutQuint);
TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint);
}
#region Filtering support
public void ApplyFilter(Func<Mod, bool>? filter)
{
Filtered.Value = filter != null && !filter.Invoke(Mod);
}
private void updateFilterState()
{
this.FadeTo(Filtered.Value ? 0 : 1);
}
#endregion
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -63,11 +64,17 @@ namespace osu.Game.Overlays.Profile.Header
};
}
private CancellationTokenSource cancellationTokenSource;
private void updateDisplay(APIUser user)
{
var badges = user.Badges;
cancellationTokenSource?.Cancel();
cancellationTokenSource = new CancellationTokenSource();
badgeFlowContainer.Clear();
var badges = user.Badges;
if (badges?.Length > 0)
{
Show();
@ -79,7 +86,7 @@ namespace osu.Game.Overlays.Profile.Header
{
// load in stable order regardless of async load order.
badgeFlowContainer.Insert(displayIndex, asyncBadge);
});
}, cancellationTokenSource.Token);
}
}
else
@ -87,5 +94,11 @@ namespace osu.Game.Overlays.Profile.Header
Hide();
}
}
protected override void Dispose(bool isDisposing)
{
cancellationTokenSource?.Cancel();
base.Dispose(isDisposing);
}
}
}

View File

@ -23,7 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
{
Children = new Drawable[]
{
new SettingsSlider<double, OffsetSlider>
new SettingsSlider<double, TimeSlider>
{
LabelText = AudioSettingsStrings.AudioOffset,
Current = config.GetBindable<double>(OsuSetting.AudioOffset),
@ -35,10 +35,5 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
}
};
}
private class OffsetSlider : OsuSliderBar<double>
{
public override LocalisableString TooltipText => Current.Value.ToString(@"0ms");
}
}
}

View File

@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
private FillFlowContainer<SettingsSlider<float>> scalingSettings;
private readonly IBindable<Display> currentDisplay = new Bindable<Display>();
private readonly Bindable<Display> currentDisplay = new Bindable<Display>();
private readonly IBindableList<WindowMode> windowModes = new BindableList<WindowMode>();
private Bindable<ScalingMode> scalingMode;
@ -39,6 +39,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
private OsuGameBase game { get; set; }
private SettingsDropdown<Size> resolutionDropdown;
private SettingsDropdown<Display> displayDropdown;
private SettingsDropdown<WindowMode> windowModeDropdown;
private Bindable<float> scalingPositionX;
@ -72,6 +73,12 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
ItemSource = windowModes,
Current = config.GetBindable<WindowMode>(FrameworkSetting.WindowMode),
},
displayDropdown = new DisplaySettingsDropdown
{
LabelText = GraphicsSettingsStrings.Display,
Items = host.Window?.Displays,
Current = currentDisplay,
},
resolutionDropdown = new ResolutionSettingsDropdown
{
LabelText = GraphicsSettingsStrings.Resolution,
@ -142,7 +149,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
windowModeDropdown.Current.BindValueChanged(mode =>
{
updateResolutionDropdown();
updateDisplayModeDropdowns();
windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? GraphicsSettingsStrings.NotFullscreenNote : default;
}, true);
@ -168,7 +175,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
.Distinct());
}
updateResolutionDropdown();
updateDisplayModeDropdowns();
}), true);
scalingMode.BindValueChanged(mode =>
@ -183,12 +190,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
// initial update bypasses transforms
updateScalingModeVisibility();
void updateResolutionDropdown()
void updateDisplayModeDropdowns()
{
if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen)
resolutionDropdown.Show();
else
resolutionDropdown.Hide();
if (displayDropdown.Items.Count() > 1)
displayDropdown.Show();
else
displayDropdown.Hide();
}
void updateScalingModeVisibility()
@ -243,6 +255,19 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
public override LocalisableString TooltipText => base.TooltipText + "x";
}
private class DisplaySettingsDropdown : SettingsDropdown<Display>
{
protected override OsuDropdown<Display> CreateDropdown() => new DisplaySettingsDropdownControl();
private class DisplaySettingsDropdownControl : DropdownControl
{
protected override LocalisableString GenerateItemText(Display item)
{
return $"{item.Index}: {item.Name} ({item.Bounds.Width}x{item.Bounds.Height})";
}
}
}
private class ResolutionSettingsDropdown : SettingsDropdown<Size>
{
protected override OsuDropdown<Size> CreateDropdown() => new ResolutionDropdownControl();

View File

@ -134,6 +134,9 @@ namespace osu.Game.Overlays.Settings.Sections
private void updateSelectedSkinFromConfig()
{
if (!skinDropdown.Items.Any())
return;
Live<SkinInfo> skin = null;
if (Guid.TryParse(configBindable.Value, out var configId))

View File

@ -35,18 +35,13 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
LabelText = UserInterfaceStrings.Parallax,
Current = config.GetBindable<bool>(OsuSetting.MenuParallax)
},
new SettingsSlider<float, TimeSlider>
new SettingsSlider<double, TimeSlider>
{
LabelText = UserInterfaceStrings.HoldToConfirmActivationTime,
Current = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay),
Current = config.GetBindable<double>(OsuSetting.UIHoldActivationDelay),
KeyboardStep = 50
},
};
}
private class TimeSlider : OsuSliderBar<float>
{
public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
}
}
}

View File

@ -22,8 +22,9 @@ namespace osu.Game.Overlays
{
public class SettingsToolboxGroup : Container, IExpandable
{
public const int CONTAINER_WIDTH = 270;
private const float transition_duration = 250;
private const int container_width = 270;
private const int border_thickness = 2;
private const int header_height = 30;
private const int corner_radius = 5;
@ -49,7 +50,7 @@ namespace osu.Game.Overlays
public SettingsToolboxGroup(string title)
{
AutoSizeAxes = Axes.Y;
Width = container_width;
Width = CONTAINER_WIDTH;
Masking = true;
CornerRadius = corner_radius;
BorderColour = Color4.Black;
@ -201,7 +202,5 @@ namespace osu.Game.Overlays
}
protected override Container<Drawable> Content => content;
protected override bool OnMouseDown(MouseDownEvent e) => true;
}
}

View File

@ -7,6 +7,7 @@ using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@ -100,7 +101,12 @@ namespace osu.Game.Overlays
cancellationToken?.Cancel();
request?.Cancel();
request = new GetWikiRequest(e.NewValue);
string[] values = e.NewValue.Split('/', 2);
if (values.Length > 1 && LanguageExtensions.TryParseCultureCode(values[0], out var language))
request = new GetWikiRequest(values[1], language);
else
request = new GetWikiRequest(e.NewValue);
Loading.Show();

View File

@ -63,9 +63,8 @@ namespace osu.Game.Rulesets.Difficulty
// calculate total score
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
scoreProcessor.HighestCombo.Value = perfectPlay.MaxCombo;
scoreProcessor.Mods.Value = perfectPlay.Mods;
perfectPlay.TotalScore = (long)scoreProcessor.GetImmediateScore(ScoringMode.Standardised, perfectPlay.MaxCombo, statistics);
perfectPlay.TotalScore = (long)scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, perfectPlay);
// compute rank achieved
// default to SS, then adjust the rank with mods

View File

@ -5,8 +5,19 @@ using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mods
{
/// <summary>
/// An interface for <see cref="Mod"/>s that are updated every frame by a <see cref="Playfield"/>.
/// </summary>
public interface IUpdatableByPlayfield : IApplicableMod
{
/// <summary>
/// Update this <see cref="Mod"/>.
/// </summary>
/// <param name="playfield">The main <see cref="Playfield"/></param>
/// <remarks>
/// This method is called once per frame during gameplay by the main <see cref="Playfield"/> only.
/// To access nested <see cref="Playfield"/>s, use <see cref="Playfield.NestedPlayfields"/>.
/// </remarks>
void Update(Playfield playfield);
}
}

View File

@ -0,0 +1,269 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Audio;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mods
{
public class ModAdaptiveSpeed : Mod, IApplicableToRate, IApplicableToDrawableHitObject, IApplicableToBeatmap, IUpdatableByPlayfield
{
public override string Name => "Adaptive Speed";
public override string Acronym => "AS";
public override string Description => "Let track speed adapt to you.";
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) };
[SettingSource("Initial rate", "The starting speed of the track")]
public BindableNumber<double> InitialRate { get; } = new BindableDouble
{
MinValue = 0.5,
MaxValue = 2,
Default = 1,
Value = 1,
Precision = 0.01
};
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
public BindableBool AdjustPitch { get; } = new BindableBool
{
Default = true,
Value = true
};
/// <summary>
/// The instantaneous rate of the track.
/// Every frame this mod will attempt to smoothly adjust this to meet <see cref="targetRate"/>.
/// </summary>
public BindableNumber<double> SpeedChange { get; } = new BindableDouble
{
MinValue = min_allowable_rate,
MaxValue = max_allowable_rate,
Default = 1,
Value = 1
};
// The two constants below denote the maximum allowable range of rates that `SpeedChange` can take.
// The range is purposefully wider than the range of values that `InitialRate` allows
// in order to give some leeway for change even when extreme initial rates are chosen.
private const double min_allowable_rate = 0.4d;
private const double max_allowable_rate = 2.5d;
// The two constants below denote the maximum allowable change in rate caused by a single hit
// This prevents sudden jolts caused by a badly-timed hit.
private const double min_allowable_rate_change = 0.9d;
private const double max_allowable_rate_change = 1.11d;
// Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast.
private const double rate_change_on_miss = 0.95d;
private ITrack track;
private double targetRate = 1d;
/// <summary>
/// The number of most recent track rates (approximated from how early/late each object was hit relative to the previous object)
/// which should be averaged to calculate <see cref="targetRate"/>.
/// </summary>
private const int recent_rate_count = 8;
/// <summary>
/// Stores the most recent <see cref="recent_rate_count"/> approximated track rates
/// which are averaged to calculate the value of <see cref="targetRate"/>.
/// </summary>
/// <remarks>
/// This list is used as a double-ended queue with fixed capacity
/// (items can be enqueued/dequeued at either end of the list).
/// When time is elapsing forward, items are dequeued from the start and enqueued onto the end of the list.
/// When time is being rewound, items are dequeued from the end and enqueued onto the start of the list.
/// </remarks>
/// <example>
/// <para>
/// The track rate approximation is calculated as follows:
/// </para>
/// <para>
/// Consider a hitobject which ends at 1000ms, and assume that its preceding hitobject ends at 500ms.
/// This gives a time difference of 1000 - 500 = 500ms.
/// </para>
/// <para>
/// Now assume that the user hit this object at 980ms rather than 1000ms.
/// When compared to the preceding hitobject, this gives 980 - 500 = 480ms.
/// </para>
/// <para>
/// With the above assumptions, the player is rushing / hitting early, which means that the track should speed up to match.
/// Therefore, the approximated target rate for this object would be equal to 500 / 480 * <see cref="InitialRate"/>.
/// </para>
/// </example>
private readonly List<double> recentRates = Enumerable.Repeat(1d, recent_rate_count).ToList();
/// <summary>
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the latest end time of any other object
/// that precedes the end time of the given object.
/// This can be loosely interpreted as the end time of the preceding hit object in rulesets that do not have overlapping hit objects.
/// </summary>
private readonly Dictionary<HitObject, double> precedingEndTimes = new Dictionary<HitObject, double>();
/// <summary>
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the track rate dequeued from
/// <see cref="recentRates"/> (i.e. the oldest value in the queue) when the object is hit. If the hit is then reverted,
/// the mapped value can be re-introduced to <see cref="recentRates"/> to properly rewind the queue.
/// </summary>
private readonly Dictionary<HitObject, double> ratesForRewinding = new Dictionary<HitObject, double>();
public ModAdaptiveSpeed()
{
InitialRate.BindValueChanged(val =>
{
SpeedChange.Value = val.NewValue;
targetRate = val.NewValue;
});
AdjustPitch.BindValueChanged(adjustPitchChanged);
}
public void ApplyToTrack(ITrack track)
{
this.track = track;
InitialRate.TriggerChange();
AdjustPitch.TriggerChange();
recentRates.Clear();
recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count));
}
public void ApplyToSample(DrawableSample sample)
{
sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange);
}
public void Update(Playfield playfield)
{
SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetRate, 50, playfield.Clock.ElapsedFrameTime);
}
public double ApplyToRate(double time, double rate = 1) => rate * InitialRate.Value;
public void ApplyToDrawableHitObject(DrawableHitObject drawable)
{
drawable.OnNewResult += (o, result) =>
{
if (ratesForRewinding.ContainsKey(result.HitObject)) return;
if (!shouldProcessResult(result)) return;
ratesForRewinding.Add(result.HitObject, recentRates[0]);
recentRates.RemoveAt(0);
recentRates.Add(Math.Clamp(getRelativeRateChange(result) * SpeedChange.Value, min_allowable_rate, max_allowable_rate));
updateTargetRate();
};
drawable.OnRevertResult += (o, result) =>
{
if (!ratesForRewinding.ContainsKey(result.HitObject)) return;
if (!shouldProcessResult(result)) return;
recentRates.Insert(0, ratesForRewinding[result.HitObject]);
ratesForRewinding.Remove(result.HitObject);
recentRates.RemoveAt(recentRates.Count - 1);
updateTargetRate();
};
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
var hitObjects = getAllApplicableHitObjects(beatmap.HitObjects).ToList();
var endTimes = hitObjects.Select(x => x.GetEndTime()).OrderBy(x => x).Distinct().ToList();
foreach (HitObject hitObject in hitObjects)
{
int index = endTimes.BinarySearch(hitObject.GetEndTime());
if (index < 0) index = ~index; // BinarySearch returns the next larger element in bitwise complement if there's no exact match
index -= 1;
if (index >= 0)
precedingEndTimes.Add(hitObject, endTimes[index]);
}
}
private void adjustPitchChanged(ValueChangedEvent<bool> adjustPitchSetting)
{
track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange);
track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange);
}
private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue)
=> adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo;
private IEnumerable<HitObject> getAllApplicableHitObjects(IEnumerable<HitObject> hitObjects)
{
foreach (var hitObject in hitObjects)
{
if (!(hitObject.HitWindows is HitWindows.EmptyHitWindows))
yield return hitObject;
foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects))
yield return nested;
}
}
private bool shouldProcessResult(JudgementResult result)
{
if (!result.Type.AffectsAccuracy()) return false;
if (!precedingEndTimes.ContainsKey(result.HitObject)) return false;
return true;
}
private double getRelativeRateChange(JudgementResult result)
{
if (!result.IsHit)
return rate_change_on_miss;
double prevEndTime = precedingEndTimes[result.HitObject];
return Math.Clamp(
(result.HitObject.GetEndTime() - prevEndTime) / (result.TimeAbsolute - prevEndTime),
min_allowable_rate_change,
max_allowable_rate_change
);
}
/// <summary>
/// Update <see cref="targetRate"/> based on the values in <see cref="recentRates"/>.
/// </summary>
private void updateTargetRate()
{
// Compare values in recentRates to see how consistent the player's speed is
// If the player hits half of the notes too fast and the other half too slow: Abs(consistency) = 0
// If the player hits all their notes too fast or too slow: Abs(consistency) = recent_rate_count - 1
int consistency = 0;
for (int i = 1; i < recentRates.Count; i++)
{
consistency += Math.Sign(recentRates[i] - recentRates[i - 1]);
}
// Scale the rate adjustment based on consistency
targetRate = Interpolation.Lerp(targetRate, recentRates.Average(), Math.Abs(consistency) / (recent_rate_count - 1d));
}
}
}

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mods
public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value;
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) };
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed) };
public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x";
}

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