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

Merge branch 'master' into aim_prob_with_penalty

# Conflicts:
#	osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
#	osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
This commit is contained in:
Nathen 2024-10-12 21:52:41 -04:00
commit 03d963d4cb
614 changed files with 17310 additions and 4965 deletions

View File

@ -21,7 +21,7 @@
]
},
"ppy.localisationanalyser.tools": {
"version": "2024.517.0",
"version": "2024.802.0",
"commands": [
"localisation"
]

View File

@ -121,9 +121,7 @@ jobs:
build-only-ios:
name: Build only (iOS)
# `macos-13` is required, because the newest Microsoft.iOS.Sdk versions require Xcode 14.3.
# TODO: can be changed to `macos-latest` once `macos-13` becomes latest (currently in beta: https://github.com/actions/runner-images/tree/main#available-images)
runs-on: macos-13
runs-on: macos-latest
timeout-minutes: 60
steps:
- name: Checkout
@ -135,10 +133,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
run: dotnet workload install maui-ios
- name: Select Xcode 15.2
run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer
run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
- name: Build
run: dotnet build -c Debug osu.iOS

View File

@ -104,6 +104,25 @@ env:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
jobs:
master-environment:
name: Save master environment
runs-on: ubuntu-latest
outputs:
HEAD: ${{ steps.get-head.outputs.HEAD }}
steps:
- name: Checkout osu
uses: actions/checkout@v4
with:
ref: master
sparse-checkout: |
README.md
- name: Get HEAD ref
id: get-head
run: |
ref=$(git log -1 --format='%H')
echo "HEAD=https://github.com/${{ github.repository }}/commit/${ref}" >> "${GITHUB_OUTPUT}"
check-permissions:
name: Check permissions
runs-on: ubuntu-latest
@ -121,7 +140,7 @@ jobs:
create-comment:
name: Create PR comment
needs: check-permissions
needs: [ master-environment, check-permissions ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps:
@ -158,7 +177,7 @@ jobs:
environment:
name: Setup environment
needs: directory
needs: [ master-environment, directory ]
runs-on: self-hosted
env:
VARS_JSON: ${{ toJSON(vars) }}
@ -182,6 +201,10 @@ jobs:
fi
done
- name: Add master environment
run: |
sed -i "s;^OSU_A=.*$;OSU_A=${{ needs.master-environment.outputs.HEAD }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: |
@ -361,8 +384,7 @@ jobs:
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert
create_if_not_exists: false
mode: recreate
message: |
Target: ${{ needs.generator.outputs.TARGET }}
Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
@ -372,8 +394,7 @@ jobs:
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert
create_if_not_exists: false
mode: recreate
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.720.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.1009.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -6,28 +6,29 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game;
using osu.Game.Screens.Play;
namespace osu.Android
{
public partial class GameplayScreenRotationLocker : Component
{
private Bindable<bool> localUserPlaying = null!;
private IBindable<LocalUserPlayingState> localUserPlaying = null!;
[Resolved]
private OsuGameActivity gameActivity { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(OsuGame game)
private void load(ILocalUserPlayInfo localUserPlayInfo)
{
localUserPlaying = game.LocalUserPlaying.GetBoundCopy();
localUserPlaying = localUserPlayInfo.PlayingState.GetBoundCopy();
localUserPlaying.BindValueChanged(updateLock, true);
}
private void updateLock(ValueChangedEvent<bool> userPlaying)
private void updateLock(ValueChangedEvent<LocalUserPlayingState> userPlaying)
{
gameActivity.RunOnUiThread(() =>
{
gameActivity.RequestedOrientation = userPlaying.NewValue ? ScreenOrientation.Locked : gameActivity.DefaultOrientation;
gameActivity.RequestedOrientation = userPlaying.NewValue != LocalUserPlayingState.NotPlaying ? ScreenOrientation.Locked : gameActivity.DefaultOrientation;
});
}
}

View File

@ -80,7 +80,7 @@ namespace osu.Android
host.Window.CursorState |= CursorState.Hidden;
}
protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager();
protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier();
protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo();

View File

@ -279,10 +279,12 @@ namespace osu.Desktop
// As above, discord decides that *non-empty* strings shorter than 2 characters cannot possibly be valid input, because... reasons?
// And yes, that is two *characters*, or *codepoints*, not *bytes* as further down below (as determined by empirical testing).
// That seems very questionable, and isn't even documented anywhere. So to *make it* accept such valid input,
// just tack on enough of U+200B ZERO WIDTH SPACEs at the end.
if (str.Length < 2)
return str.PadRight(2, '\u200B');
// Also, spaces don't count. Because reasons, clearly.
// That all seems very questionable, and isn't even documented anywhere. So to *make it* accept such valid input,
// just tack on enough of U+200B ZERO WIDTH SPACEs at the end. After making sure to trim whitespace.
string trimmed = str.Trim();
if (trimmed.Length < 2)
return trimmed.PadRight(2, '\u200B');
if (Encoding.UTF8.GetByteCount(str) <= 128)
return str;

View File

@ -2,10 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Microsoft.Win32;
using osu.Desktop.Performance;
using osu.Desktop.Security;
@ -95,42 +95,20 @@ namespace osu.Desktop
return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", "");
}
public static bool IsPackageManaged => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"));
protected override UpdateManager CreateUpdateManager()
{
string? packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER");
if (!string.IsNullOrEmpty(packageManaged))
if (IsPackageManaged)
return new NoActionUpdateManager();
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
Debug.Assert(OperatingSystem.IsWindows());
return new SquirrelUpdateManager();
default:
return new SimpleUpdateManager();
}
return new VelopackUpdateManager();
}
public override bool RestartAppWhenExited()
{
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
Debug.Assert(OperatingSystem.IsWindows());
// Of note, this is an async method in squirrel that adds an arbitrary delay before returning
// likely to ensure the external process is in a good state.
//
// We're not waiting on that here, but the outro playing before the actual exit should be enough
// to cover this.
Squirrel.UpdateManager.RestartAppWhenExited().FireAndForget();
return true;
}
return base.RestartAppWhenExited();
Task.Run(() => Velopack.UpdateExe.Start()).FireAndForget();
return true;
}
protected override void LoadComplete()

View File

@ -14,7 +14,7 @@ using osu.Game;
using osu.Game.IPC;
using osu.Game.Tournament;
using SDL;
using Squirrel;
using Velopack;
namespace osu.Desktop
{
@ -31,19 +31,11 @@ namespace osu.Desktop
[STAThread]
public static void Main(string[] args)
{
/*
* WARNING: DO NOT PLACE **ANY** CODE ABOVE THE FOLLOWING BLOCK!
*
* Logic handling Squirrel MUST run before EVERYTHING if you do not want to break it.
* To be more precise: Squirrel is internally using a rather... crude method to determine whether it is running under NUnit,
* namely by checking loaded assemblies:
* https://github.com/clowd/Clowd.Squirrel/blob/24427217482deeeb9f2cacac555525edfc7bd9ac/src/Squirrel/SimpleSplat/PlatformModeDetector.cs#L17-L32
*
* If it finds ANY assembly from the ones listed above - REGARDLESS of the reason why it is loaded -
* the app will then do completely broken things like:
* - not creating system shortcuts (as the logic is if'd out if "running tests")
* - not exiting after the install / first-update / uninstall hooks are ran (as the `Environment.Exit()` calls are if'd out if "running tests")
*/
// IMPORTANT DON'T IGNORE: For general sanity, velopack's setup needs to run before anything else.
// This has bitten us in the rear before (bricked updater), and although the underlying issue from
// last time has been fixed, let's not tempt fate.
setupVelopack();
if (OperatingSystem.IsWindows())
{
var windowsVersion = Environment.OSVersion.Version;
@ -66,8 +58,6 @@ namespace osu.Desktop
return;
}
}
setupSquirrel();
}
// NVIDIA profiles are based on the executable name of a process.
@ -177,32 +167,28 @@ namespace osu.Desktop
return false;
}
[SupportedOSPlatform("windows")]
private static void setupSquirrel()
private static void setupVelopack()
{
SquirrelAwareApp.HandleEvents(onInitialInstall: (_, tools) =>
if (OsuGameDesktop.IsPackageManaged)
{
tools.CreateShortcutForThisExe();
tools.CreateUninstallerRegistryEntry();
WindowsAssociationManager.InstallAssociations();
}, onAppUpdate: (_, tools) =>
{
tools.CreateUninstallerRegistryEntry();
WindowsAssociationManager.UpdateAssociations();
}, onAppUninstall: (_, tools) =>
{
tools.RemoveShortcutForThisExe();
tools.RemoveUninstallerRegistryEntry();
WindowsAssociationManager.UninstallAssociations();
}, onEveryRun: (_, _, _) =>
{
// While setting the `ProcessAppUserModelId` fixes duplicate icons/shortcuts on the taskbar, it currently
// causes the right-click context menu to function incorrectly.
//
// This may turn out to be non-required after an alternative solution is implemented.
// see https://github.com/clowd/Clowd.Squirrel/issues/24
// tools.SetProcessAppUserModelId();
});
Logger.Log("Updates are being managed by an external provider. Skipping Velopack setup.");
return;
}
var app = VelopackApp.Build();
if (OperatingSystem.IsWindows())
configureWindows(app);
app.Run();
}
[SupportedOSPlatform("windows")]
private static void configureWindows(VelopackApp app)
{
app.WithFirstRun(_ => WindowsAssociationManager.InstallAssociations());
app.WithAfterUpdateFastCallback(_ => WindowsAssociationManager.UpdateAssociations());
app.WithBeforeUninstallFastCallback(_ => WindowsAssociationManager.UninstallAssociations());
}
}
}

View File

@ -1,180 +0,0 @@
// 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.Runtime.Versioning;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Logging;
using osu.Game;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Screens.Play;
using Squirrel.SimpleSplat;
using Squirrel.Sources;
using LogLevel = Squirrel.SimpleSplat.LogLevel;
using UpdateManager = osu.Game.Updater.UpdateManager;
namespace osu.Desktop.Updater
{
[SupportedOSPlatform("windows")]
public partial class SquirrelUpdateManager : UpdateManager
{
private Squirrel.UpdateManager? updateManager;
private INotificationOverlay notificationOverlay = null!;
public Task PrepareUpdateAsync() => Squirrel.UpdateManager.RestartAppWhenExited();
private static readonly Logger logger = Logger.GetLogger("updater");
/// <summary>
/// Whether an update has been downloaded but not yet applied.
/// </summary>
private bool updatePending;
private readonly SquirrelLogger squirrelLogger = new SquirrelLogger();
[Resolved]
private OsuGameBase game { get; set; } = null!;
[Resolved]
private ILocalUserPlayInfo? localUserInfo { get; set; }
[BackgroundDependencyLoader]
private void load(INotificationOverlay notifications)
{
notificationOverlay = notifications;
SquirrelLocator.CurrentMutable.Register(() => squirrelLogger, typeof(ILogger));
}
protected override async Task<bool> PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false);
private async Task<bool> checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification? notification = null)
{
// should we schedule a retry on completion of this check?
bool scheduleRecheck = true;
const string? github_token = null; // TODO: populate.
try
{
// Avoid any kind of update checking while gameplay is running.
if (localUserInfo?.IsPlaying.Value == true)
return false;
updateManager ??= new Squirrel.UpdateManager(new GithubSource(@"https://github.com/ppy/osu", github_token, false), @"osulazer");
var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false);
if (info.ReleasesToApply.Count == 0)
{
if (updatePending)
{
// the user may have dismissed the completion notice, so show it again.
notificationOverlay.Post(new UpdateApplicationCompleteNotification
{
Activated = () =>
{
restartToApplyUpdate();
return true;
},
});
return true;
}
// no updates available. bail and retry later.
return false;
}
scheduleRecheck = false;
if (notification == null)
{
notification = new UpdateProgressNotification
{
CompletionClickAction = restartToApplyUpdate,
};
Schedule(() => notificationOverlay.Post(notification));
}
notification.StartDownload();
try
{
await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f).ConfigureAwait(false);
notification.StartInstall();
await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f).ConfigureAwait(false);
notification.State = ProgressNotificationState.Completed;
updatePending = true;
}
catch (Exception e)
{
if (useDeltaPatching)
{
logger.Add(@"delta patching failed; will attempt full download!");
// could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959)
// try again without deltas.
await checkForUpdateAsync(false, notification).ConfigureAwait(false);
}
else
{
// In the case of an error, a separate notification will be displayed.
notification.FailDownload();
Logger.Error(e, @"update failed!");
}
}
}
catch (Exception)
{
// we'll ignore this and retry later. can be triggered by no internet connection or thread abortion.
scheduleRecheck = true;
}
finally
{
if (scheduleRecheck)
{
// check again in 30 minutes.
Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30);
}
}
return true;
}
private bool restartToApplyUpdate()
{
PrepareUpdateAsync()
.ContinueWith(_ => Schedule(() => game.AttemptExit()));
return true;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
updateManager?.Dispose();
}
private class SquirrelLogger : ILogger, IDisposable
{
public LogLevel Level { get; set; } = LogLevel.Info;
public void Write(string message, LogLevel logLevel)
{
if (logLevel < Level)
return;
logger.Add(message);
}
public void Dispose()
{
}
}
}
}

View File

@ -0,0 +1,148 @@
// 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.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Logging;
using osu.Game;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Screens.Play;
using Velopack;
using Velopack.Sources;
namespace osu.Desktop.Updater
{
public partial class VelopackUpdateManager : Game.Updater.UpdateManager
{
private readonly UpdateManager updateManager;
private INotificationOverlay notificationOverlay = null!;
[Resolved]
private OsuGameBase game { get; set; } = null!;
[Resolved]
private ILocalUserPlayInfo? localUserInfo { get; set; }
private bool isInGameplay => localUserInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying;
private UpdateInfo? pendingUpdate;
public VelopackUpdateManager()
{
updateManager = new UpdateManager(new GithubSource(@"https://github.com/ppy/osu", null, false), new UpdateOptions
{
AllowVersionDowngrade = true,
});
}
[BackgroundDependencyLoader]
private void load(INotificationOverlay notifications)
{
notificationOverlay = notifications;
}
protected override async Task<bool> PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false);
private async Task<bool> checkForUpdateAsync()
{
// whether to check again in 30 minutes. generally only if there's an error or no update was found (yet).
bool scheduleRecheck = false;
try
{
// Avoid any kind of update checking while gameplay is running.
if (isInGameplay)
{
scheduleRecheck = true;
return true;
}
// TODO: we should probably be checking if there's a more recent update, rather than shortcutting here.
// Velopack does support this scenario (see https://github.com/ppy/osu/pull/28743#discussion_r1743495975).
if (pendingUpdate != null)
{
// If there is an update pending restart, show the notification to restart again.
notificationOverlay.Post(new UpdateApplicationCompleteNotification
{
Activated = () =>
{
Task.Run(restartToApplyUpdate);
return true;
}
});
return true;
}
pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false);
// No update is available. We'll check again later.
if (pendingUpdate == null)
{
scheduleRecheck = true;
return false;
}
// An update is found, let's notify the user and start downloading it.
UpdateProgressNotification notification = new UpdateProgressNotification
{
CompletionClickAction = () =>
{
Task.Run(restartToApplyUpdate);
return true;
},
};
runOutsideOfGameplay(() => notificationOverlay.Post(notification));
notification.StartDownload();
try
{
await updateManager.DownloadUpdatesAsync(pendingUpdate, p => notification.Progress = p / 100f).ConfigureAwait(false);
runOutsideOfGameplay(() => notification.State = ProgressNotificationState.Completed);
}
catch (Exception e)
{
// In the case of an error, a separate notification will be displayed.
scheduleRecheck = true;
notification.FailDownload();
Logger.Error(e, @"update failed!");
}
}
catch (Exception e)
{
// we'll ignore this and retry later. can be triggered by no internet connection or thread abortion.
scheduleRecheck = true;
Logger.Log($@"update check failed ({e.Message})");
}
finally
{
if (scheduleRecheck)
{
Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30);
}
}
return true;
}
private void runOutsideOfGameplay(Action action)
{
if (isInGameplay)
{
Scheduler.AddDelayed(() => runOutsideOfGameplay(action), 1000);
return;
}
action();
}
private async Task restartToApplyUpdate()
{
await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
}
}
}

View File

@ -13,7 +13,7 @@ namespace osu.Desktop.Windows
public partial class GameplayWinKeyBlocker : Component
{
private Bindable<bool> disableWinKey = null!;
private IBindable<bool> localUserPlaying = null!;
private IBindable<LocalUserPlayingState> localUserPlaying = null!;
private IBindable<bool> isActive = null!;
[Resolved]
@ -22,7 +22,7 @@ namespace osu.Desktop.Windows
[BackgroundDependencyLoader]
private void load(ILocalUserPlayInfo localUserInfo, OsuConfigManager config)
{
localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy();
localUserPlaying = localUserInfo.PlayingState.GetBoundCopy();
localUserPlaying.BindValueChanged(_ => updateBlocking());
isActive = host.IsActive.GetBoundCopy();
@ -34,7 +34,7 @@ namespace osu.Desktop.Windows
private void updateBlocking()
{
bool shouldDisable = isActive.Value && disableWinKey.Value && localUserPlaying.Value;
bool shouldDisable = isActive.Value && disableWinKey.Value && localUserPlaying.Value == LocalUserPlayingState.Playing;
if (shouldDisable)
host.InputThread.Scheduler.Add(WindowsKey.Disable);

View File

@ -13,5 +13,7 @@ namespace osu.Desktop.Windows
private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!;
public static string Lazer => Path.Join(icon_directory, "lazer.ico");
public static string Beatmap => Path.Join(icon_directory, "beatmap.ico");
}
}

View File

@ -40,10 +40,10 @@ namespace osu.Desktop.Windows
private static readonly FileAssociation[] file_associations =
{
new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer),
new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer),
new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Lazer),
new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Lazer),
new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Beatmap),
new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Beatmap),
new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Beatmap),
new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Beatmap),
};
private static readonly UriAssociation[] uri_associations =

BIN
osu.Desktop/beatmap.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

View File

@ -5,6 +5,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>A free-to-win rhythm game. Rhythm is just a *click* away!</Description>
<AssemblyName>osu!</AssemblyName>
<AssemblyTitle>osu!(lazer)</AssemblyTitle>
<Title>osu!</Title>
<Product>osu!(lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon>
@ -23,9 +24,9 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Clowd.Squirrel" Version="2.11.1" />
<PackageReference Include="System.IO.Packaging" Version="8.0.0" />
<PackageReference Include="System.IO.Packaging" Version="8.0.1" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.630-g9c52e40" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />

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 BenchmarkDotNet.Attributes;
using osu.Framework.Utils;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Benchmarks
{
public class BenchmarkGeometryUtils : BenchmarkTest
{
[Params(100, 1000, 2000, 4000, 8000, 10000)]
public int N;
private Vector2[] points = null!;
public override void SetUp()
{
points = new Vector2[N];
for (int i = 0; i < points.Length; ++i)
points[i] = new Vector2(RNG.Next(512), RNG.Next(384));
}
[Benchmark]
public void MinimumEnclosingCircle() => GeometryUtils.MinimumEnclosingCircle(points);
}
}

View File

@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
contentContainer.Playfield.HitObjectContainer.Add(hitObject);
}
protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
protected override SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint)
{
var result = base.SnapForBlueprint(blueprint);
result.Time = Math.Round(HitObjectContainer.TimeAtScreenSpacePosition(result.ScreenSpacePosition) / TIME_SNAP) * TIME_SNAP;

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableBananaShower((BananaShower)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
protected override void AddHitObject(DrawableHitObject hitObject)
{

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableFruit((Fruit)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
[Test]
public void TestFruitPlacementPosition()

View File

@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
private void addMoveAndClickSteps(double time, float position, bool end = false)
{

View File

@ -4,7 +4,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Skinning;
@ -19,20 +18,17 @@ namespace osu.Game.Rulesets.Catch.Tests
protected override Ruleset CreatePlayerRuleset() => new CatchRuleset();
[Test]
public void TestLegacyHUDComboCounterHidden([Values] bool withModifiedSkin)
public void TestLegacyHUDComboCounterNotExistent([Values] bool withModifiedSkin)
{
if (withModifiedSkin)
{
AddStep("change component scale", () => Player.ChildrenOfType<LegacyScoreCounter>().First().Scale = new Vector2(2f));
AddStep("update target", () => Player.ChildrenOfType<SkinComponentsContainer>().ForEach(LegacySkin.UpdateDrawableTarget));
AddStep("update target", () => Player.ChildrenOfType<SkinnableContainer>().ForEach(LegacySkin.UpdateDrawableTarget));
AddStep("exit player", () => Player.Exit());
CreateTest();
}
AddAssert("legacy HUD combo counter hidden", () =>
{
return Player.ChildrenOfType<LegacyComboCounter>().All(c => c.ChildrenOfType<Container>().Single().Alpha == 0f);
});
AddAssert("legacy HUD combo counter not added", () => !Player.ChildrenOfType<LegacyDefaultComboCounter>().Any());
}
}
}

View File

@ -248,7 +248,8 @@ namespace osu.Game.Rulesets.Catch.Tests
{
AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true));
AddStep("catch fruit", () => attemptCatch(new Fruit()));
AddAssert("correct hit lighting colour", () => catcher.ChildrenOfType<HitExplosion>().First()?.Entry?.ObjectColour == this.ChildrenOfType<DrawableCatchHitObject>().First().AccentColour.Value);
AddAssert("correct hit lighting colour",
() => catcher.ChildrenOfType<HitExplosion>().First()?.Entry?.ObjectColour == this.ChildrenOfType<DrawableCatchHitObject>().First().AccentColour.Value);
}
[Test]
@ -259,6 +260,16 @@ namespace osu.Game.Rulesets.Catch.Tests
AddAssert("no hit lighting", () => !catcher.ChildrenOfType<HitExplosion>().Any());
}
[Test]
public void TestAllExplodedObjectsAtUniquePositions()
{
AddStep("catch normal fruit", () => attemptCatch(new Fruit()));
AddStep("catch normal fruit", () => attemptCatch(new Fruit { IndexInBeatmap = 2, LastInCombo = true }));
AddAssert("two fruit at distinct x coordinates",
() => this.ChildrenOfType<CaughtFruit>().Select(f => f.DrawPosition.X).Distinct(),
() => Has.Exactly(2).Items);
}
private void checkPlate(int count) => AddAssert($"{count} objects on the plate", () => catcher.CaughtObjects.Count() == count);
private void checkState(CatcherAnimationState state) => AddAssert($"catcher state is {state}", () => catcher.CurrentState == state);

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Localisation;
@ -31,6 +32,7 @@ using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch
{
@ -223,10 +225,28 @@ namespace osu.Game.Rulesets.Catch
public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new DifficultySection(),
new ColoursSection(),
new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(SetupScreen.SPACING),
Children = new Drawable[]
{
new ResourcesSection
{
RelativeSizeAxes = Axes.X,
},
new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
}
},
new DesignSection(),
];
public override IBeatmapVerifier CreateBeatmapVerifier() => new CatchBeatmapVerifier();
@ -254,5 +274,7 @@ namespace osu.Game.Rulesets.Catch
return adjustedDifficulty;
}
public override bool EditorShowScrollSpeed => false;
}
}

View File

@ -5,7 +5,7 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Catch
{
public class CatchSkinComponentLookup : GameplaySkinComponentLookup<CatchSkinComponents>
public class CatchSkinComponentLookup : SkinComponentLookup<CatchSkinComponents>
{
public CatchSkinComponentLookup(CatchSkinComponents component)
: base(component)

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchDifficultyCalculator : DifficultyCalculator
{
private const double star_scaling_factor = 0.153;
private const double difficulty_multiplier = 4.59;
private float halfCatcherWidth;
@ -41,10 +40,10 @@ namespace osu.Game.Rulesets.Catch.Difficulty
CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
{
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor,
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier,
Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0,
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
MaxCombo = beatmap.GetMaxCombo(),
};
return attributes;

View File

@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
private const float normalized_hitobject_radius = 41.0f;
private const double direction_change_bonus = 21.0;
protected override double SkillMultiplier => 900;
protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 0.2;
protected override double DecayWeight => 0.94;

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class BananaShowerCompositionTool : HitObjectCompositionTool
public class BananaShowerCompositionTool : CompositionTool
{
public BananaShowerCompositionTool()
: base(nameof(BananaShower))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override PlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
}
}

View File

@ -9,7 +9,7 @@ using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public partial class CatchPlacementBlueprint<THitObject> : PlacementBlueprint
public partial class CatchPlacementBlueprint<THitObject> : HitObjectPlacementBlueprint
where THitObject : CatchHitObject, new()
{
protected new THitObject HitObject => (THitObject)base.HitObject;

View File

@ -8,6 +8,7 @@ using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
@ -172,7 +173,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () =>
{
editablePath.AddVertex(rightMouseDownPosition);
});
})
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft))
};
}
protected override void Dispose(bool isDisposing)

View File

@ -18,7 +18,9 @@ namespace osu.Game.Rulesets.Catch.Edit
// The implementation below is probably correct but should be checked if/when exposed via controls.
float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime());
float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX);
float previousEndX = (before as JuiceStream)?.EndX ?? ((CatchHitObject)before).EffectiveX;
float actualDistance = Math.Abs(previousEndX - ((CatchHitObject)after).EffectiveX);
return actualDistance / expectedDistance;
}

View File

@ -84,7 +84,7 @@ namespace osu.Game.Rulesets.Catch.Edit
protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
@ -114,6 +114,26 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Repeat)
return false;
handleToggleViaKey(e);
return base.OnKeyDown(e);
}
protected override void OnKeyUp(KeyUpEvent e)
{
handleToggleViaKey(e);
base.OnKeyUp(e);
}
private void handleToggleViaKey(KeyboardEvent key)
{
DistanceSnapProvider.HandleToggleViaKey(key);
}
public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All)
{
var result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType);

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class FruitCompositionTool : HitObjectCompositionTool
public class FruitCompositionTool : CompositionTool
{
public FruitCompositionTool()
: base(nameof(Fruit))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
}
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class JuiceStreamCompositionTool : HitObjectCompositionTool
public class JuiceStreamCompositionTool : CompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
}

View File

@ -21,11 +21,9 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
public Bindable<Color4> AccentColour { get; } = new Bindable<Color4>();
public Bindable<bool> HyperDash { get; } = new Bindable<bool>();
public Bindable<int> IndexInBeatmap { get; } = new Bindable<int>();
public Vector2 DisplayPosition => DrawPosition;
public Vector2 DisplaySize => Size * Scale;
public float DisplayRotation => Rotation;
public double DisplayStartTime => HitObject.StartTime;
/// <summary>
@ -44,19 +42,6 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
Size = new Vector2(CatchHitObject.OBJECT_RADIUS * 2);
}
/// <summary>
/// Copies the hit object visual state from another <see cref="IHasCatchObjectState"/> object.
/// </summary>
public virtual void CopyStateFrom(IHasCatchObjectState objectState)
{
HitObject = objectState.HitObject;
Scale = Vector2.Divide(objectState.DisplaySize, Size);
Rotation = objectState.DisplayRotation;
AccentColour.Value = objectState.AccentColour.Value;
HyperDash.Value = objectState.HyperDash.Value;
IndexInBeatmap.Value = objectState.IndexInBeatmap.Value;
}
protected override void FreeAfterUse()
{
ClearTransforms();
@ -64,5 +49,16 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
base.FreeAfterUse();
}
public void RestoreState(CatchObjectState state)
{
HitObject = state.HitObject;
AccentColour.Value = state.AccentColour;
HyperDash.Value = state.HyperDash;
IndexInBeatmap.Value = state.IndexInBeatmap;
Position = state.DisplayPosition;
Scale = Vector2.Divide(state.DisplaySize, Size);
Rotation = state.DisplayRotation;
}
}
}

View File

@ -1,10 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
@ -36,23 +38,43 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
StartTimeBindable.BindValueChanged(_ => UpdateComboColour());
}
private float startScale;
private float endScale;
private float startAngle;
private float endAngle;
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
const float end_scale = 0.6f;
const float random_scale_range = 1.6f;
ScalingContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RandomSingle(3)))
.Then().ScaleTo(HitObject.Scale * end_scale, HitObject.TimePreempt);
startScale = end_scale + random_scale_range * RandomSingle(3);
endScale = end_scale;
ScalingContainer.RotateTo(getRandomAngle(1))
.Then()
.RotateTo(getRandomAngle(2), HitObject.TimePreempt);
startAngle = getRandomAngle(1);
endAngle = getRandomAngle(2);
float getRandomAngle(int series) => 180 * (RandomSingle(series) * 2 - 1);
}
protected override void Update()
{
base.Update();
double preemptProgress = (Time.Current - (HitObject.StartTime - InitialLifetimeOffset)) / HitObject.TimePreempt;
// Clamp scale and rotation at the point of bananas being caught, else let them freely extrapolate.
if (Result.IsHit)
preemptProgress = Math.Min(1, preemptProgress);
ScalingContainer.Scale = new Vector2(HitObject.Scale * (float)Interpolation.Lerp(startScale, endScale, preemptProgress));
ScalingContainer.Rotation = (float)Interpolation.Lerp(startAngle, endAngle, preemptProgress);
}
public override void PlaySamples()
{
base.PlaySamples();

View File

@ -2,7 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
@ -28,15 +28,24 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
_ => new DropletPiece());
}
private float startRotation;
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// roughly matches osu-stable
float startRotation = RandomSingle(1) * 20;
double duration = HitObject.TimePreempt + 2000;
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
startRotation = RandomSingle(1) * 20;
}
ScalingContainer.RotateTo(startRotation).RotateTo(startRotation + 720, duration);
protected override void Update()
{
base.Update();
// No clamping for droplets. They should be considered indefinitely spinning regardless of time.
// They also never end up on the plate, so they shouldn't stop spinning when caught.
double preemptProgress = (Time.Current - (HitObject.StartTime - InitialLifetimeOffset)) / (HitObject.TimePreempt + 2000);
ScalingContainer.Rotation = (float)Interpolation.Lerp(startRotation, startRotation + 720, preemptProgress);
}
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
@ -32,7 +31,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
base.UpdateInitialTransforms();
ScalingContainer.RotateTo((RandomSingle(1) - 0.5f) * 40);
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
ScalingContainer.Rotation = (RandomSingle(1) - 0.5f) * 40;
}
}
}

View File

@ -37,6 +37,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
/// </summary>
protected readonly Container ScalingContainer;
public Vector2 DisplayPosition => DrawPosition;
public Vector2 DisplaySize => ScalingContainer.Size * ScalingContainer.Scale;
public float DisplayRotation => ScalingContainer.Rotation;
@ -95,5 +97,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
base.OnFree();
}
public void RestoreState(CatchObjectState state) => throw new NotSupportedException("Cannot restore state into a drawable catch hitobject.");
}
}

View File

@ -13,17 +13,35 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
public interface IHasCatchObjectState
{
PalpableCatchHitObject HitObject { get; }
double DisplayStartTime { get; }
Bindable<Color4> AccentColour { get; }
Bindable<bool> HyperDash { get; }
Bindable<int> IndexInBeatmap { get; }
double DisplayStartTime { get; }
Vector2 DisplayPosition { get; }
Vector2 DisplaySize { get; }
float DisplayRotation { get; }
void RestoreState(CatchObjectState state);
}
public static class HasCatchObjectStateExtensions
{
public static CatchObjectState SaveState(this IHasCatchObjectState target) => new CatchObjectState(
target.HitObject,
target.AccentColour.Value,
target.HyperDash.Value,
target.IndexInBeatmap.Value,
target.DisplayPosition,
target.DisplaySize,
target.DisplayRotation);
}
public readonly record struct CatchObjectState(
PalpableCatchHitObject HitObject,
Color4 AccentColour,
bool HyperDash,
int IndexInBeatmap,
Vector2 DisplayPosition,
Vector2 DisplaySize,
float DisplayRotation);
}

View File

@ -16,9 +16,12 @@ namespace osu.Game.Rulesets.Catch.Replays
{
public new CatchBeatmap Beatmap => (CatchBeatmap)base.Beatmap;
private readonly float halfCatcherWidth;
public CatchAutoGenerator(IBeatmap beatmap)
: base(beatmap)
{
halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f;
}
protected override void GenerateFrames()
@ -47,10 +50,7 @@ namespace osu.Game.Rulesets.Catch.Replays
bool dashRequired = speedRequired > Catcher.BASE_WALK_SPEED;
bool impossibleJump = speedRequired > Catcher.BASE_DASH_SPEED;
// todo: get correct catcher size, based on difficulty CS.
const float catcher_width_half = Catcher.BASE_SIZE * 0.3f * 0.5f;
if (lastPosition - catcher_width_half < h.EffectiveX && lastPosition + catcher_width_half > h.EffectiveX)
if (lastPosition - halfCatcherWidth < h.EffectiveX && lastPosition + halfCatcherWidth > h.EffectiveX)
{
// we are already in the correct range.
lastTime = h.StartTime;

View File

@ -4,8 +4,8 @@
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
@ -28,76 +28,94 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
if (lookup is SkinComponentsContainerLookup containerLookup)
switch (lookup)
{
switch (containerLookup.Target)
{
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents:
var components = base.GetDrawableComponent(lookup) as Container;
if (providesComboCounter && components != null)
{
// catch may provide its own combo counter; hide the default.
// todo: this should be done in an elegant way per ruleset, defining which HUD skin components should be displayed.
foreach (var legacyComboCounter in components.OfType<LegacyComboCounter>())
legacyComboCounter.HiddenByRulesetImplementation = false;
}
return components;
}
}
if (lookup is CatchSkinComponentLookup catchSkinComponent)
{
switch (catchSkinComponent.Component)
{
case CatchSkinComponents.Fruit:
if (hasPear)
return new LegacyFruitPiece();
case GlobalSkinnableContainerLookup containerLookup:
// Only handle per ruleset defaults here.
if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup);
// we don't have enough assets to display these components (this is especially the case on a "beatmap" skin).
if (!IsProvidingLegacyResources)
return null;
case CatchSkinComponents.Banana:
if (GetTexture("fruit-bananas") != null)
return new LegacyBananaPiece();
// Our own ruleset components default.
switch (containerLookup.Lookup)
{
case GlobalSkinnableContainers.MainHUDComponents:
// todo: remove CatchSkinComponents.CatchComboCounter and refactor LegacyCatchComboCounter to be added here instead.
return new DefaultSkinComponentsContainer(container =>
{
var keyCounter = container.OfType<LegacyKeyCounterDisplay>().FirstOrDefault();
return null;
if (keyCounter != null)
{
// set the anchor to top right so that it won't squash to the return button to the top
keyCounter.Anchor = Anchor.CentreRight;
keyCounter.Origin = Anchor.TopRight;
keyCounter.Position = new Vector2(0, -40) * 1.6f;
}
})
{
Children = new Drawable[]
{
new LegacyKeyCounterDisplay(),
}
};
}
case CatchSkinComponents.Droplet:
if (GetTexture("fruit-drop") != null)
return new LegacyDropletPiece();
return null;
return null;
case CatchSkinComponentLookup catchSkinComponent:
switch (catchSkinComponent.Component)
{
case CatchSkinComponents.Fruit:
if (hasPear)
return new LegacyFruitPiece();
case CatchSkinComponents.Catcher:
decimal version = GetConfig<SkinConfiguration.LegacySetting, decimal>(SkinConfiguration.LegacySetting.Version)?.Value ?? 1;
return null;
if (version < 2.3m)
{
if (hasOldStyleCatcherSprite())
return new LegacyCatcherOld();
}
case CatchSkinComponents.Banana:
if (GetTexture("fruit-bananas") != null)
return new LegacyBananaPiece();
if (hasNewStyleCatcherSprite())
return new LegacyCatcherNew();
return null;
return null;
case CatchSkinComponents.Droplet:
if (GetTexture("fruit-drop") != null)
return new LegacyDropletPiece();
case CatchSkinComponents.CatchComboCounter:
if (providesComboCounter)
return new LegacyCatchComboCounter();
return null;
return null;
case CatchSkinComponents.Catcher:
decimal version = GetConfig<SkinConfiguration.LegacySetting, decimal>(SkinConfiguration.LegacySetting.Version)?.Value ?? 1;
case CatchSkinComponents.HitExplosion:
if (hasOldStyleCatcherSprite() || hasNewStyleCatcherSprite())
return new LegacyHitExplosion();
if (version < 2.3m)
{
if (hasOldStyleCatcherSprite())
return new LegacyCatcherOld();
}
return null;
if (hasNewStyleCatcherSprite())
return new LegacyCatcherNew();
default:
throw new UnsupportedSkinComponentException(lookup);
}
return null;
case CatchSkinComponents.CatchComboCounter:
if (providesComboCounter)
return new LegacyCatchComboCounter();
return null;
case CatchSkinComponents.HitExplosion:
if (hasOldStyleCatcherSprite() || hasNewStyleCatcherSprite())
return new LegacyHitExplosion();
return null;
default:
throw new UnsupportedSkinComponentException(lookup);
}
}
return base.GetDrawableComponent(lookup);

View File

@ -85,9 +85,25 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
protected void SetTexture(Texture? texture, Texture? overlayTexture)
{
colouredSprite.Texture = texture;
overlaySprite.Texture = overlayTexture;
hyperSprite.Texture = texture;
// Sizes are reset due to an arguable osu!framework bug where Sprite retains the size of the first set texture.
if (colouredSprite.Texture != texture)
{
colouredSprite.Size = Vector2.Zero;
colouredSprite.Texture = texture;
}
if (overlaySprite.Texture != overlayTexture)
{
overlaySprite.Size = Vector2.Zero;
overlaySprite.Texture = overlayTexture;
}
if (hyperSprite.Texture != texture)
{
hyperSprite.Size = Vector2.Zero;
hyperSprite.Texture = texture;
}
}
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Buffers;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
@ -362,7 +363,7 @@ namespace osu.Game.Rulesets.Catch.UI
if (caughtObject == null) return;
caughtObject.CopyStateFrom(drawableObject);
caughtObject.RestoreState(drawableObject.SaveState());
caughtObject.Anchor = Anchor.TopCentre;
caughtObject.Position = position;
caughtObject.Scale *= caught_fruit_scale_adjust;
@ -411,41 +412,50 @@ namespace osu.Game.Rulesets.Catch.UI
}
}
private CaughtObject getDroppedObject(CaughtObject caughtObject)
private CaughtObject getDroppedObject(CatchObjectState state)
{
var droppedObject = getCaughtObject(caughtObject.HitObject);
var droppedObject = getCaughtObject(state.HitObject);
Debug.Assert(droppedObject != null);
droppedObject.CopyStateFrom(caughtObject);
droppedObject.RestoreState(state);
droppedObject.Anchor = Anchor.TopLeft;
droppedObject.Position = caughtObjectContainer.ToSpaceOfOtherDrawable(caughtObject.DrawPosition, droppedObjectTarget);
droppedObject.Position = caughtObjectContainer.ToSpaceOfOtherDrawable(state.DisplayPosition, droppedObjectTarget);
return droppedObject;
}
private void clearPlate(DroppedObjectAnimation animation)
{
var caughtObjects = caughtObjectContainer.Children.ToArray();
int caughtCount = caughtObjectContainer.Children.Count;
CatchObjectState[] states = ArrayPool<CatchObjectState>.Shared.Rent(caughtCount);
caughtObjectContainer.Clear(false);
try
{
for (int i = 0; i < caughtCount; i++)
states[i] = caughtObjectContainer.Children[i].SaveState();
// Use the already returned PoolableDrawables for new objects
var droppedObjects = caughtObjects.Select(getDroppedObject).ToArray();
caughtObjectContainer.Clear(false);
droppedObjectTarget.AddRange(droppedObjects);
foreach (var droppedObject in droppedObjects)
applyDropAnimation(droppedObject, animation);
for (int i = 0; i < caughtCount; i++)
{
CaughtObject obj = getDroppedObject(states[i]);
droppedObjectTarget.Add(obj);
applyDropAnimation(obj, animation);
}
}
finally
{
ArrayPool<CatchObjectState>.Shared.Return(states);
}
}
private void removeFromPlate(CaughtObject caughtObject, DroppedObjectAnimation animation)
{
CatchObjectState state = caughtObject.SaveState();
caughtObjectContainer.Remove(caughtObject, false);
var droppedObject = getDroppedObject(caughtObject);
var droppedObject = getDroppedObject(state);
droppedObjectTarget.Add(droppedObject);
applyDropAnimation(droppedObject, animation);
}

View File

@ -110,9 +110,9 @@ namespace osu.Game.Rulesets.Catch.UI
if (Catcher.Dashing || Catcher.HyperDashing)
{
double generationInterval = Catcher.HyperDashing ? 25 : 50;
const double trail_generation_interval = 16;
if (Time.Current - catcherTrails.LastDashTrailTime >= generationInterval)
if (Time.Current - catcherTrails.LastDashTrailTime >= trail_generation_interval)
displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing);
}

View File

@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
});
}
protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
protected override SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint)
{
double time = column.TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position);
var pos = column.ScreenSpacePositionAtTime(time);

View File

@ -13,6 +13,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
public partial class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHoldNote((HoldNote)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint();
}
}

View File

@ -20,10 +20,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test]
public void TestKeyCountChange()
{
LabelledSliderBar<float> keyCount = null!;
FormSliderBar<float> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<LabelledSliderBar<float>>().First(), () => Is.Not.Null);
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<float>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8", () =>
{

View File

@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Visual;
@ -92,5 +93,30 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddAssert("second object flipped", () => second.StartTime, () => Is.EqualTo(250));
AddAssert("third object flipped", () => third.StartTime, () => Is.EqualTo(1250));
}
[Test]
public void TestOffScreenObjectsRemainSelectedOnColumnChange()
{
AddStep("create objects", () =>
{
for (int i = 0; i < 20; ++i)
EditorBeatmap.Add(new Note { StartTime = 1000 * i, Column = 0 });
});
AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("start drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Column>().First());
InputManager.PressButton(MouseButton.Left);
});
AddStep("end drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last());
InputManager.ReleaseButton(MouseButton.Left);
});
AddAssert("all objects in last column", () => EditorBeatmap.HitObjects.All(ho => ((ManiaHitObject)ho).Column == 3));
AddAssert("all objects remain selected", () => EditorBeatmap.SelectedHitObjects.SequenceEqual(EditorBeatmap.HitObjects));
}
}
}

View File

@ -64,6 +64,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
private Note getNote() => this.ChildrenOfType<DrawableNote>().FirstOrDefault()?.HitObject;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableNote((Note)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint();
}
}

View File

@ -12,8 +12,8 @@ namespace osu.Game.Rulesets.Mania.Tests
{
[TestCase(ManiaAction.Key1)]
[TestCase(ManiaAction.Key1, ManiaAction.Key2)]
[TestCase(ManiaAction.Special1)]
[TestCase(ManiaAction.Key8)]
[TestCase(ManiaAction.Key5)]
[TestCase(ManiaAction.Key9)]
public void TestEncodeDecodeSingleStage(params ManiaAction[] actions)
{
var beatmap = new ManiaBeatmap(new StageDefinition(9));
@ -29,11 +29,11 @@ namespace osu.Game.Rulesets.Mania.Tests
[TestCase(ManiaAction.Key1)]
[TestCase(ManiaAction.Key1, ManiaAction.Key2)]
[TestCase(ManiaAction.Special1)]
[TestCase(ManiaAction.Special2)]
[TestCase(ManiaAction.Special1, ManiaAction.Special2)]
[TestCase(ManiaAction.Special1, ManiaAction.Key5)]
[TestCase(ManiaAction.Key3)]
[TestCase(ManiaAction.Key8)]
[TestCase(ManiaAction.Key3, ManiaAction.Key8)]
[TestCase(ManiaAction.Key3, ManiaAction.Key6)]
[TestCase(ManiaAction.Key10)]
public void TestEncodeDecodeDualStage(params ManiaAction[] actions)
{
var beatmap = new ManiaBeatmap(new StageDefinition(5));

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
public abstract partial class ManiaSkinnableTestScene : SkinnableTestScene
{
[Cached(Type = typeof(IScrollingInfo))]
private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo();
protected readonly TestScrollingInfo ScrollingInfo = new TestScrollingInfo();
[Cached]
private readonly StageDefinition stage = new StageDefinition(4);
@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
protected ManiaSkinnableTestScene()
{
scrollingInfo.Direction.Value = ScrollingDirection.Down;
ScrollingInfo.Direction.Value = ScrollingDirection.Down;
Add(new Box
{
@ -43,16 +43,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
[Test]
public void TestScrollingDown()
{
AddStep("change direction to down", () => scrollingInfo.Direction.Value = ScrollingDirection.Down);
AddStep("change direction to down", () => ScrollingInfo.Direction.Value = ScrollingDirection.Down);
}
[Test]
public void TestScrollingUp()
{
AddStep("change direction to up", () => scrollingInfo.Direction.Value = ScrollingDirection.Up);
AddStep("change direction to up", () => ScrollingInfo.Direction.Value = ScrollingDirection.Up);
}
private class TestScrollingInfo : IScrollingInfo
protected class TestScrollingInfo : IScrollingInfo
{
public readonly Bindable<ScrollingDirection> Direction = new Bindable<ScrollingDirection>();

View File

@ -0,0 +1,95 @@
// 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.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Skinning.Argon;
using osu.Game.Rulesets.Mania.Skinning.Legacy;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
public partial class TestSceneComboCounter : ManiaSkinnableTestScene
{
[Cached]
private ScoreProcessor scoreProcessor = new ScoreProcessor(new ManiaRuleset());
[Test]
public void TestDisplay()
{
setup(Anchor.Centre);
AddRepeatStep("perform hit", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Great }), 20);
AddStep("perform miss", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss }));
}
[Test]
public void TestAnchorOrigin()
{
AddStep("set direction down", () => ScrollingInfo.Direction.Value = ScrollingDirection.Down);
setup(Anchor.TopCentre, 20);
AddStep("set direction up", () => ScrollingInfo.Direction.Value = ScrollingDirection.Up);
check(Anchor.BottomCentre, -20);
AddStep("set direction up", () => ScrollingInfo.Direction.Value = ScrollingDirection.Up);
setup(Anchor.BottomCentre, -20);
AddStep("set direction down", () => ScrollingInfo.Direction.Value = ScrollingDirection.Down);
check(Anchor.TopCentre, 20);
AddStep("set direction down", () => ScrollingInfo.Direction.Value = ScrollingDirection.Down);
setup(Anchor.Centre, 20);
AddStep("set direction up", () => ScrollingInfo.Direction.Value = ScrollingDirection.Up);
check(Anchor.Centre, 20);
AddStep("set direction up", () => ScrollingInfo.Direction.Value = ScrollingDirection.Up);
setup(Anchor.Centre, -20);
AddStep("set direction down", () => ScrollingInfo.Direction.Value = ScrollingDirection.Down);
check(Anchor.Centre, -20);
}
private void setup(Anchor anchor, float y = 0)
{
AddStep($"setup {anchor} {y}", () => SetContents(s =>
{
var container = new Container
{
RelativeSizeAxes = Axes.Both,
};
if (s is ArgonSkin)
container.Add(new ArgonManiaComboCounter());
else if (s is LegacySkin)
container.Add(new LegacyManiaComboCounter());
else
container.Add(new LegacyManiaComboCounter());
container.Child.Anchor = anchor;
container.Child.Origin = Anchor.Centre;
container.Child.Y = y;
return container;
}));
}
private void check(Anchor anchor, float y)
{
AddAssert($"check {anchor} {y}", () =>
{
foreach (var combo in this.ChildrenOfType<ISerialisableDrawable>())
{
var drawableCombo = (Drawable)combo;
if (drawableCombo.Anchor != anchor || drawableCombo.Y != y)
return false;
}
return true;
});
}
}
}

View File

@ -28,14 +28,20 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
AddStep("Show " + result.GetDescription(), () =>
{
SetContents(_ =>
new DrawableManiaJudgement(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement())
{
Type = result
}, null)
{
var drawableManiaJudgement = new DrawableManiaJudgement
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
});
};
drawableManiaJudgement.Apply(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement())
{
Type = result
}, null);
return drawableManiaJudgement;
});
// for test purposes, undo the Y adjustment related to the `ScorePosition` legacy positioning config value
// (see `LegacyManiaJudgementPiece.load()`).

View File

@ -3,15 +3,22 @@
using System.Collections.Generic;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
public partial class TestScenePlayfield : ManiaSkinnableTestScene
{
[Cached]
private ScoreProcessor scoreProcessor = new ScoreProcessor(new ManiaRuleset());
private List<StageDefinition> stageDefinitions = new List<StageDefinition>();
[Test]
@ -29,6 +36,9 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
Child = new ManiaPlayfield(stageDefinitions)
});
});
AddRepeatStep("perform hit", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Perfect }), 20);
AddStep("perform miss", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss }));
}
[TestCase(2)]
@ -54,6 +64,9 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
}
});
});
AddRepeatStep("perform hit", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Perfect }), 20);
AddStep("perform miss", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss }));
}
protected override IBeatmap CreateBeatmapForSkinProvider()

View File

@ -14,12 +14,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
SetContents(_ =>
{
ManiaAction normalAction = ManiaAction.Key1;
ManiaAction specialAction = ManiaAction.Special1;
ManiaAction action = ManiaAction.Key1;
return new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4)
{
Child = new Stage(0, new StageDefinition(4), ref normalAction, ref specialAction)
Child = new Stage(0, new StageDefinition(4), ref action)
};
});
}

View File

@ -36,8 +36,8 @@ namespace osu.Game.Rulesets.Mania.Tests
Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames");
Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time");
Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time");
Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Special1), "Special1 has not been pressed");
Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Special1), "Special1 has not been released");
Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed");
Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released");
}
[Test]
@ -57,8 +57,8 @@ namespace osu.Game.Rulesets.Mania.Tests
Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames");
Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time");
Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time");
Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Special1), "Special1 has not been pressed");
Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Special1), "Special1 has not been released");
Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed");
Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released");
}
[Test]

View File

@ -0,0 +1,36 @@
// 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.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests
{
public partial class TestSceneManiaPlayerLegacySkin : LegacySkinPlayerTestScene
{
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
// play with a converted beatmap to allow dual stages mod to work.
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(new RulesetInfo());
protected override bool HasCustomSteps => true;
[Test]
public void TestSingleStage()
{
AddStep("Load single stage", LoadPlayer);
AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1);
}
[Test]
public void TestDualStage()
{
AddStep("Load dual stage", () => LoadPlayer(new Mod[] { new ManiaModDualStages() }));
AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1);
}
}
}

View File

@ -131,9 +131,7 @@ namespace osu.Game.Rulesets.Mania.Tests
private ScrollingTestContainer createStage(ScrollingDirection direction, ManiaAction action)
{
var specialAction = ManiaAction.Special1;
var stage = new Stage(0, new StageDefinition(2), ref action, ref specialAction);
var stage = new Stage(0, new StageDefinition(2), ref action);
stages.Add(stage);
return new ScrollingTestContainer(direction)

View File

@ -6,7 +6,6 @@ using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@ -271,7 +270,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
Duration = endTimeData.Duration,
Column = column,
Samples = HitObject.Samples,
NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? defaultNodeSamples
NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? HoldNote.CreateDefaultNodeSamples(HitObject)
});
}
else if (HitObject is IHasXPosition)
@ -286,16 +285,6 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
return pattern;
}
/// <remarks>
/// osu!mania-specific beatmaps in stable only play samples at the start of the hold note.
/// </remarks>
private List<IList<HitSampleInfo>> defaultNodeSamples
=> new List<IList<HitSampleInfo>>
{
HitObject.Samples,
new List<HitSampleInfo>()
};
}
}
}

View File

@ -24,12 +24,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
public class ManiaDifficultyCalculator : DifficultyCalculator
{
private const double star_scaling_factor = 0.018;
private const double difficulty_multiplier = 0.018;
private readonly bool isForCurrentRuleset;
private readonly double originalOverallDifficulty;
public override int Version => 20230817;
public override int Version => 20241007;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
StarRating = skills[0].DifficultyValue() * star_scaling_factor,
StarRating = skills[0].DifficultyValue() * difficulty_multiplier,
Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window.
// This is done the way it is to introduce fractional differences in order to match osu-stable for the time being.

View File

@ -38,9 +38,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
scoreAccuracy = calculateCustomAccuracy();
// Arbitrary initial value for scaling pp in order to standardize distributions across game modes.
// The specific number has no intrinsic meaning and can be adjusted as needed.
double multiplier = 8.0;
double multiplier = 1.0;
if (score.Mods.Any(m => m is ModNoFail))
multiplier *= 0.75;
@ -59,9 +57,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
private double computeDifficultyValue(ManiaDifficultyAttributes attributes)
{
double difficultyValue = Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve
* Math.Max(0, 5 * scoreAccuracy - 4) // From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy
* (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes
double difficultyValue = 8.0 * Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve
* Math.Max(0, 5 * scoreAccuracy - 4) // From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy
* (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes
return difficultyValue;
}

View File

@ -45,18 +45,15 @@ namespace osu.Game.Rulesets.Mania
LeftKeys = stage1LeftKeys,
RightKeys = stage1RightKeys,
SpecialKey = InputKey.V,
SpecialAction = ManiaAction.Special1,
NormalActionStart = ManiaAction.Key1
}.GenerateKeyBindingsFor(singleStageVariant, out var nextNormal);
}.GenerateKeyBindingsFor(singleStageVariant);
var stage2Bindings = new VariantMappingGenerator
{
LeftKeys = stage2LeftKeys,
RightKeys = stage2RightKeys,
SpecialKey = InputKey.B,
SpecialAction = ManiaAction.Special2,
NormalActionStart = nextNormal
}.GenerateKeyBindingsFor(singleStageVariant, out _);
ActionStart = (ManiaAction)singleStageVariant,
}.GenerateKeyBindingsFor(singleStageVariant);
return stage1Bindings.Concat(stage2Bindings);
}

View File

@ -15,7 +15,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public abstract partial class ManiaPlacementBlueprint<T> : PlacementBlueprint
public abstract partial class ManiaPlacementBlueprint<T> : HitObjectPlacementBlueprint
where T : ManiaHitObject
{
protected new T HitObject => (T)base.HitObject;

View File

@ -9,7 +9,7 @@ using osu.Game.Rulesets.Mania.Edit.Blueprints;
namespace osu.Game.Rulesets.Mania.Edit
{
public class HoldNoteCompositionTool : HitObjectCompositionTool
public class HoldNoteCompositionTool : CompositionTool
{
public HoldNoteCompositionTool()
: base("Hold")
@ -18,6 +18,6 @@ namespace osu.Game.Rulesets.Mania.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
}
}

View File

@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new NoteCompositionTool(),
new HoldNoteCompositionTool()

View File

@ -104,8 +104,10 @@ namespace osu.Game.Rulesets.Mania.Edit
int minColumn = int.MaxValue;
int maxColumn = int.MinValue;
var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType<ManiaHitObject>().ToArray();
// find min/max in an initial pass before actually performing the movement.
foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType<ManiaHitObject>())
foreach (var obj in selectedObjects)
{
if (obj.Column < minColumn)
minColumn = obj.Column;
@ -121,6 +123,13 @@ namespace osu.Game.Rulesets.Mania.Edit
((ManiaHitObject)h).Column += columnDelta;
maniaPlayfield.Add(h);
});
// `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with this operation's usage pattern,
// leading to selections being sometimes partially dropped if some of the objects being moved are off screen
// (check blame for detailed explanation).
// thus, ensure that selection is preserved manually.
EditorBeatmap.SelectedHitObjects.Clear();
EditorBeatmap.SelectedHitObjects.AddRange(selectedObjects);
}
}
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mania.Objects;
namespace osu.Game.Rulesets.Mania.Edit
{
public class NoteCompositionTool : HitObjectCompositionTool
public class NoteCompositionTool : CompositionTool
{
public NoteCompositionTool()
: base(nameof(Note))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Mania.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
}
}

View File

@ -19,12 +19,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
private LabelledSliderBar<float> keyCountSlider { get; set; } = null!;
private LabelledSwitchButton specialStyle { get; set; } = null!;
private LabelledSliderBar<float> healthDrainSlider { get; set; } = null!;
private LabelledSliderBar<float> overallDifficultySlider { get; set; } = null!;
private LabelledSliderBar<double> baseVelocitySlider { get; set; } = null!;
private LabelledSliderBar<double> tickRateSlider { get; set; } = null!;
private FormSliderBar<float> keyCountSlider { get; set; } = null!;
private FormCheckBox specialStyle { get; set; } = null!;
private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private FormSliderBar<double> tickRateSlider { get; set; } = null!;
[Resolved]
private Editor? editor { get; set; }
@ -37,77 +37,81 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
Children = new Drawable[]
{
keyCountSlider = new LabelledSliderBar<float>
keyCountSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsCsMania,
FixedLabelWidth = LABEL_WIDTH,
Description = "The number of columns in the beatmap",
Caption = BeatmapsetsStrings.ShowStatsCsMania,
HintText = "The number of columns in the beatmap",
Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 1,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
specialStyle = new LabelledSwitchButton
specialStyle = new FormCheckBox
{
Label = "Use special (N+1) style",
FixedLabelWidth = LABEL_WIDTH,
Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.",
Caption = "Use special (N+1) style",
HintText = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.",
Current = { Value = Beatmap.BeatmapInfo.SpecialStyle }
},
healthDrainSlider = new LabelledSliderBar<float>
healthDrainSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsDrain,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.DrainRateDescription,
Caption = BeatmapsetsStrings.ShowStatsDrain,
HintText = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
overallDifficultySlider = new LabelledSliderBar<float>
overallDifficultySlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsAccuracy,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.OverallDifficultyDescription,
Caption = BeatmapsetsStrings.ShowStatsAccuracy,
HintText = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
baseVelocitySlider = new LabelledSliderBar<double>
baseVelocitySlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.BaseVelocity,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.BaseVelocityDescription,
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
MinValue = 0.4,
MaxValue = 3.6,
Precision = 0.01f,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
tickRateSlider = new LabelledSliderBar<double>
tickRateSlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.TickRate,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.TickRateDescription,
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
MinValue = 1,
MaxValue = 4,
Precision = 1,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
};

View File

@ -19,16 +19,8 @@ namespace osu.Game.Rulesets.Mania
public enum ManiaAction
{
[Description("Special 1")]
Special1 = 1,
[Description("Special 2")]
Special2,
// This offsets the start value of normal keys in-case we add more special keys
// above at a later time, without breaking replays/configs.
[Description("Key 1")]
Key1 = 10,
Key1,
[Description("Key 2")]
Key2,

View File

@ -419,9 +419,12 @@ namespace osu.Game.Rulesets.Mania
return new ManiaFilterCriteria();
}
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new ManiaDifficultySection(),
new ResourcesSection(),
new DesignSection(),
];
public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null)

View File

@ -5,7 +5,7 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania
{
public class ManiaSkinComponentLookup : GameplaySkinComponentLookup<ManiaSkinComponents>
public class ManiaSkinComponentLookup : SkinComponentLookup<ManiaSkinComponents>
{
/// <summary>
/// Creates a new <see cref="ManiaSkinComponentLookup"/>.

View File

@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Threading;
using osu.Game.Audio;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
@ -91,6 +92,10 @@ namespace osu.Game.Rulesets.Mania.Objects
{
base.CreateNestedHitObjects(cancellationToken);
// Generally node samples will be populated by ManiaBeatmapConverter, but in a case like the editor they may not be.
// Ensure they are set to a sane default here.
NodeSamples ??= CreateDefaultNodeSamples(this);
AddNested(Head = new HeadNote
{
StartTime = StartTime,
@ -102,7 +107,7 @@ namespace osu.Game.Rulesets.Mania.Objects
{
StartTime = EndTime,
Column = Column,
Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1),
Samples = GetNodeSamples(NodeSamples.Count - 1),
});
AddNested(Body = new HoldNoteBody
@ -116,7 +121,20 @@ namespace osu.Game.Rulesets.Mania.Objects
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
public IList<HitSampleInfo> GetNodeSamples(int nodeIndex) =>
nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples;
public IList<HitSampleInfo> GetNodeSamples(int nodeIndex) => nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples;
/// <summary>
/// Create the default note samples for a hold note, based off their main sample.
/// </summary>
/// <remarks>
/// By default, osu!mania beatmaps in only play samples at the start of the hold note.
/// </remarks>
/// <param name="obj">The object to use as a basis for the head sample.</param>
/// <returns>Defaults for assigning to <see cref="HoldNote.NodeSamples"/>.</returns>
public static List<IList<HitSampleInfo>> CreateDefaultNodeSamples(HitObject obj) => new List<IList<HitSampleInfo>>
{
obj.Samples,
new List<HitSampleInfo>(),
};
}
}

View File

@ -17,28 +17,9 @@ namespace osu.Game.Rulesets.Mania.Replays
public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap;
private readonly ManiaAction[] columnActions;
public ManiaAutoGenerator(ManiaBeatmap beatmap)
: base(beatmap)
{
columnActions = new ManiaAction[Beatmap.TotalColumns];
var normalAction = ManiaAction.Key1;
var specialAction = ManiaAction.Special1;
int totalCounter = 0;
foreach (var stage in Beatmap.Stages)
{
for (int i = 0; i < stage.Columns; i++)
{
if (stage.IsSpecialColumn(i))
columnActions[totalCounter] = specialAction++;
else
columnActions[totalCounter] = normalAction++;
totalCounter++;
}
}
}
protected override void GenerateFrames()
@ -57,11 +38,11 @@ namespace osu.Game.Rulesets.Mania.Replays
switch (point)
{
case HitPoint:
actions.Add(columnActions[point.Column]);
actions.Add(ManiaAction.Key1 + point.Column);
break;
case ReleasePoint:
actions.Remove(columnActions[point.Column]);
actions.Remove(ManiaAction.Key1 + point.Column);
break;
}
}

View File

@ -1,11 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
@ -27,118 +25,27 @@ namespace osu.Game.Rulesets.Mania.Replays
public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var normalAction = ManiaAction.Key1;
var specialAction = ManiaAction.Special1;
var action = ManiaAction.Key1;
int activeColumns = (int)(legacyFrame.MouseX ?? 0);
int counter = 0;
while (activeColumns > 0)
{
bool isSpecial = isColumnAtIndexSpecial(maniaBeatmap, counter);
if ((activeColumns & 1) > 0)
Actions.Add(isSpecial ? specialAction : normalAction);
Actions.Add(action);
if (isSpecial)
specialAction++;
else
normalAction++;
counter++;
action++;
activeColumns >>= 1;
}
}
public LegacyReplayFrame ToLegacy(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
int keys = 0;
foreach (var action in Actions)
{
switch (action)
{
case ManiaAction.Special1:
keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 0);
break;
case ManiaAction.Special2:
keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 1);
break;
default:
// the index in lazer, which doesn't include special keys.
int nonSpecialKeyIndex = action - ManiaAction.Key1;
// the index inclusive of special keys.
int overallIndex = 0;
// iterate to find the index including special keys.
for (; overallIndex < maniaBeatmap.TotalColumns; overallIndex++)
{
// skip over special columns.
if (isColumnAtIndexSpecial(maniaBeatmap, overallIndex))
continue;
// found a non-special column to use.
if (nonSpecialKeyIndex == 0)
break;
// found a non-special column but not ours.
nonSpecialKeyIndex--;
}
keys |= 1 << overallIndex;
break;
}
}
keys |= 1 << (int)action;
return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None);
}
/// <summary>
/// Find the overall index (across all stages) for a specified special key.
/// </summary>
/// <param name="maniaBeatmap">The beatmap.</param>
/// <param name="specialOffset">The special key offset (0 is S1).</param>
/// <returns>The overall index for the special column.</returns>
private int getSpecialColumnIndex(ManiaBeatmap maniaBeatmap, int specialOffset)
{
for (int i = 0; i < maniaBeatmap.TotalColumns; i++)
{
if (isColumnAtIndexSpecial(maniaBeatmap, i))
{
if (specialOffset == 0)
return i;
specialOffset--;
}
}
throw new ArgumentException("Special key index is too high.", nameof(specialOffset));
}
/// <summary>
/// Check whether the column at an overall index (across all stages) is a special column.
/// </summary>
/// <param name="beatmap">The beatmap.</param>
/// <param name="index">The overall index to check.</param>
private bool isColumnAtIndexSpecial(ManiaBeatmap beatmap, int index)
{
foreach (var stage in beatmap.Stages)
{
if (index >= stage.Columns)
{
index -= stage.Columns;
continue;
}
return stage.IsSpecialColumn(index);
}
throw new ArgumentException("Column index is too high.", nameof(index));
}
}
}

View File

@ -34,8 +34,6 @@ namespace osu.Game.Rulesets.Mania
LeftKeys = leftKeys,
RightKeys = rightKeys,
SpecialKey = InputKey.Space,
SpecialAction = ManiaAction.Special1,
NormalActionStart = ManiaAction.Key1,
}.GenerateKeyBindingsFor(variant, out _);
}.GenerateKeyBindingsFor(variant);
}
}

View File

@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
public partial class ArgonManiaComboCounter : ArgonComboCounter
{
protected override bool DisplayXSymbol => false;
[Resolved]
private IScrollingInfo scrollingInfo { get; set; } = null!;
private IBindable<ScrollingDirection> direction = null!;
protected override void LoadComplete()
{
base.LoadComplete();
// the logic of flipping the position of the combo counter w.r.t. the direction does not work with "Closest" anchor,
// because it always forces the anchor to be top or bottom based on scrolling direction.
UsesFixedAnchor = true;
direction = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => updateAnchor());
// two schedules are required so that updateAnchor is executed in the next frame,
// which is when the combo counter receives its Y position by the default layout in ArgonManiaSkinTransformer.
Schedule(() => Schedule(updateAnchor));
}
private void updateAnchor()
{
// if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction
if (Anchor.HasFlag(Anchor.y1))
return;
Anchor &= ~(Anchor.y0 | Anchor.y2);
Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0;
// change the sign of the Y coordinate in line with the scrolling direction.
// i.e. if the user changes direction from down to up, the anchor is changed from top to bottom, and the Y is flipped from positive to negative here.
Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1);
}
}
}

View File

@ -2,8 +2,10 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Scoring;
@ -26,7 +28,34 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
case GlobalSkinnableContainerLookup containerLookup:
// Only handle per ruleset defaults here.
if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup);
switch (containerLookup.Lookup)
{
case GlobalSkinnableContainers.MainHUDComponents:
return new DefaultSkinComponentsContainer(container =>
{
var combo = container.ChildrenOfType<ArgonManiaComboCounter>().FirstOrDefault();
if (combo != null)
{
combo.ShowLabel.Value = false;
combo.Anchor = Anchor.TopCentre;
combo.Origin = Anchor.Centre;
combo.Y = 200;
}
})
{
new ArgonManiaComboCounter(),
};
}
return null;
case SkinComponentLookup<HitResult> resultComponent:
// This should eventually be moved to a skin setting, when supported.
if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great)
return Drawable.Empty();

View File

@ -0,0 +1,194 @@
// 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.Globalization;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
public partial class LegacyManiaComboCounter : CompositeDrawable, ISerialisableDrawable
{
public bool UsesFixedAnchor { get; set; }
public Bindable<int> Current { get; } = new BindableInt { MinValue = 0 };
/// <summary>
/// Value shown at the current moment.
/// </summary>
public virtual int DisplayedCount
{
get => displayedCount;
private set
{
if (displayedCount.Equals(value))
return;
displayedCountText.FadeTo(value == 0 ? 0 : 1);
displayedCountText.Text = value.ToString(CultureInfo.InvariantCulture);
counterContainer.Size = displayedCountText.Size;
displayedCount = value;
}
}
private int displayedCount;
private int previousValue;
private const double fade_out_duration = 100;
private const double rolling_duration = 20;
private Container counterContainer = null!;
private LegacySpriteText popOutCountText = null!;
private LegacySpriteText displayedCountText = null!;
[BackgroundDependencyLoader]
private void load(ISkinSource skin, ScoreProcessor scoreProcessor)
{
AutoSizeAxes = Axes.Both;
InternalChildren = new[]
{
counterContainer = new Container
{
AlwaysPresent = true,
Children = new[]
{
popOutCountText = new LegacySpriteText(LegacyFont.Combo)
{
Alpha = 0,
Blending = BlendingParameters.Additive,
BypassAutoSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = skin.GetManiaSkinConfig<Color4>(LegacyManiaSkinConfigurationLookups.ComboBreakColour)?.Value ?? Color4.Red,
},
displayedCountText = new LegacySpriteText(LegacyFont.Combo)
{
Alpha = 0,
AlwaysPresent = true,
BypassAutoSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
}
}
};
Current.BindTo(scoreProcessor.Combo);
}
[Resolved]
private IScrollingInfo scrollingInfo { get; set; } = null!;
private IBindable<ScrollingDirection> direction = null!;
protected override void LoadComplete()
{
base.LoadComplete();
displayedCountText.Text = popOutCountText.Text = Current.Value.ToString(CultureInfo.InvariantCulture);
Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true);
counterContainer.Size = displayedCountText.Size;
direction = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => updateAnchor());
// two schedules are required so that updateAnchor is executed in the next frame,
// which is when the combo counter receives its Y position by the default layout in LegacyManiaSkinTransformer.
Schedule(() => Schedule(updateAnchor));
}
private void updateAnchor()
{
// if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction
if (Anchor.HasFlag(Anchor.y1))
return;
Anchor &= ~(Anchor.y0 | Anchor.y2);
Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0;
// change the sign of the Y coordinate in line with the scrolling direction.
// i.e. if the user changes direction from down to up, the anchor is changed from top to bottom, and the Y is flipped from positive to negative here.
Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1);
}
private void updateCount(bool rolling)
{
int prev = previousValue;
previousValue = Current.Value;
if (!IsLoaded)
return;
if (!rolling)
{
FinishTransforms(false, nameof(DisplayedCount));
if (prev + 1 == Current.Value)
onCountIncrement();
else
onCountChange();
}
else
onCountRolling();
}
private void onCountIncrement()
{
popOutCountText.Hide();
DisplayedCount = Current.Value;
displayedCountText.ScaleTo(new Vector2(1f, 1.4f))
.ScaleTo(new Vector2(1f), 300, Easing.Out)
.FadeIn(120);
}
private void onCountChange()
{
popOutCountText.Hide();
if (Current.Value == 0)
displayedCountText.FadeOut();
DisplayedCount = Current.Value;
displayedCountText.ScaleTo(1f);
}
private void onCountRolling()
{
if (DisplayedCount > 0)
{
popOutCountText.Text = DisplayedCount.ToString(CultureInfo.InvariantCulture);
popOutCountText.FadeTo(0.8f).FadeOut(200)
.ScaleTo(1f).ScaleTo(4f, 200);
displayedCountText.FadeTo(0.5f, 300);
}
// Hides displayed count if was increasing from 0 to 1 but didn't finish
if (DisplayedCount == 0 && Current.Value == 0)
displayedCountText.FadeOut(fade_out_duration);
this.TransformTo(nameof(DisplayedCount), Current.Value, getProportionalDuration(DisplayedCount, Current.Value));
}
private double getProportionalDuration(int currentValue, int newValue)
{
double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue;
return difference * rolling_duration;
}
}
}

View File

@ -5,9 +5,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
@ -78,7 +80,37 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
case GlobalSkinnableContainerLookup containerLookup:
// Modifications for global components.
if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup);
// we don't have enough assets to display these components (this is especially the case on a "beatmap" skin).
if (!IsProvidingLegacyResources)
return null;
switch (containerLookup.Lookup)
{
case GlobalSkinnableContainers.MainHUDComponents:
return new DefaultSkinComponentsContainer(container =>
{
var combo = container.ChildrenOfType<LegacyManiaComboCounter>().FirstOrDefault();
if (combo != null)
{
combo.Anchor = Anchor.TopCentre;
combo.Origin = Anchor.Centre;
combo.Y = this.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0;
}
})
{
new LegacyManiaComboCounter(),
};
}
return null;
case SkinComponentLookup<HitResult> resultComponent:
return getResult(resultComponent.Component);
case ManiaSkinComponentLookup maniaComponent:

View File

@ -5,22 +5,12 @@
using osu.Framework.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.UI
{
public partial class DrawableManiaJudgement : DrawableJudgement
{
public DrawableManiaJudgement(JudgementResult result, DrawableHitObject judgedObject)
: base(result, judgedObject)
{
}
public DrawableManiaJudgement()
{
}
protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result);
private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece

View File

@ -66,13 +66,12 @@ namespace osu.Game.Rulesets.Mania.UI
Content = new[] { new Drawable[stageDefinitions.Count] }
});
var normalColumnAction = ManiaAction.Key1;
var specialColumnAction = ManiaAction.Special1;
var columnAction = ManiaAction.Key1;
int firstColumnIndex = 0;
for (int i = 0; i < stageDefinitions.Count; i++)
{
var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction);
var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref columnAction);
playfieldGrid.Content[0][i] = newStage;

View File

@ -99,12 +99,6 @@ namespace osu.Game.Rulesets.Mania.UI
return false;
}
protected override bool OnMouseDown(MouseDownEvent e)
{
Show();
return true;
}
protected override bool OnTouchDown(TouchDownEvent e)
{
Show();
@ -172,17 +166,6 @@ namespace osu.Game.Rulesets.Mania.UI
updateButton(false);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
updateButton(true);
return false; // handled by parent container to show overlay.
}
protected override void OnMouseUp(MouseUpEvent e)
{
updateButton(false);
}
private void updateButton(bool press)
{
if (press == isPressed)

View File

@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Mania.UI
private ISkinSource currentSkin = null!;
public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction)
public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction columnStartAction)
{
this.firstColumnIndex = firstColumnIndex;
Definition = definition;
@ -138,7 +138,7 @@ namespace osu.Game.Rulesets.Mania.UI
{
RelativeSizeAxes = Axes.Both,
Width = 1,
Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ }
Action = { Value = columnStartAction++ }
};
topLevelContainer.Add(column.TopLevelContainer.CreateProxy());

View File

@ -26,37 +26,30 @@ namespace osu.Game.Rulesets.Mania
public InputKey SpecialKey;
/// <summary>
/// The <see cref="ManiaAction"/> at which the normal columns should begin.
/// The <see cref="ManiaAction"/> at which the columns should begin.
/// </summary>
public ManiaAction NormalActionStart;
/// <summary>
/// The <see cref="ManiaAction"/> for the special column.
/// </summary>
public ManiaAction SpecialAction;
public ManiaAction ActionStart;
/// <summary>
/// Generates a list of <see cref="KeyBinding"/>s for a specific number of columns.
/// </summary>
/// <param name="columns">The number of columns that need to be bound.</param>
/// <param name="nextNormalAction">The next <see cref="ManiaAction"/> to use for normal columns.</param>
/// <returns>The keybindings.</returns>
public IEnumerable<KeyBinding> GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction)
public IEnumerable<KeyBinding> GenerateKeyBindingsFor(int columns)
{
ManiaAction currentNormalAction = NormalActionStart;
ManiaAction currentAction = ActionStart;
var bindings = new List<KeyBinding>();
for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++)
bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++));
bindings.Add(new KeyBinding(LeftKeys[i], currentAction++));
if (columns % 2 == 1)
bindings.Add(new KeyBinding(SpecialKey, SpecialAction));
bindings.Add(new KeyBinding(SpecialKey, currentAction++));
for (int i = 0; i < columns / 2; i++)
bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++));
bindings.Add(new KeyBinding(RightKeys[i], currentAction++));
nextNormalAction = currentNormalAction;
return bindings;
}
}

View File

@ -14,6 +14,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
}
}

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
moveMouseToHitObject(1);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection"));
mergeSelection();
@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
moveMouseToHitObject(1);
AddAssert("merge option not available", () => selectionHandler.ContextMenuItems?.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
mergeSelection();
AddAssert("circles not merged", () => circle1 is not null && circle2 is not null
&& EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2));
@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
moveMouseToHitObject(1);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection"));
mergeSelection();

View File

@ -24,24 +24,24 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestGridToggles()
{
AddStep("enable distance snap grid", () => InputManager.Key(Key.T));
AddStep("enable distance snap grid", () => InputManager.Key(Key.Y));
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
AddUntilStep("distance snap grid visible", () => this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
gridActive<RectangularPositionSnapGrid>(false);
AddStep("enable rectangular grid", () => InputManager.Key(Key.Y));
AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
gridActive<RectangularPositionSnapGrid>(true);
AddStep("disable distance snap grid", () => InputManager.Key(Key.T));
AddStep("disable distance snap grid", () => InputManager.Key(Key.Y));
AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
gridActive<RectangularPositionSnapGrid>(true);
AddStep("disable rectangular grid", () => InputManager.Key(Key.Y));
AddStep("disable rectangular grid", () => InputManager.Key(Key.T));
AddUntilStep("distance snap grid still hidden", () => !this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
gridActive<RectangularPositionSnapGrid>(false);
}
@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft));
AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
AddStep("enable distance snap grid", () => InputManager.Key(Key.T));
AddStep("enable distance snap grid", () => InputManager.Key(Key.Y));
AddUntilStep("distance snap grid visible", () => this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft));
AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
double distanceSnap = double.PositiveInfinity;
AddStep("enable distance snap grid", () => InputManager.Key(Key.T));
AddStep("enable distance snap grid", () => InputManager.Key(Key.Y));
AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1)));
AddUntilStep("distance snap grid visible", () => this.ChildrenOfType<OsuDistanceSnapGrid>().Any());
@ -170,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestGridSizeToggling()
{
AddStep("enable rectangular grid", () => InputManager.Key(Key.Y));
AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
AddUntilStep("rectangular grid visible", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Any());
gridSizeIs(4);

View File

@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(1).TriggerClick());
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(2).TriggerClick());
AddAssert("first object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
AddAssert("second object rotated 90deg around selection centre",

View File

@ -0,0 +1,47 @@
// 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.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderChangeStates : TestSceneOsuEditor
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[TestCase(SplineType.Catmull)]
[TestCase(SplineType.BSpline)]
[TestCase(SplineType.Linear)]
[TestCase(SplineType.PerfectCurve)]
public void TestSliderRetainsCurveTypes(SplineType splineType)
{
Slider? slider = null;
PathType pathType = new PathType(splineType);
AddStep("add slider", () => EditorBeatmap.Add(slider = new Slider
{
StartTime = 500,
Path = new SliderPath(new[]
{
new PathControlPoint(Vector2.Zero, pathType),
new PathControlPoint(new Vector2(200, 0), pathType),
})
}));
AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType));
AddStep("remove object", () => EditorBeatmap.Remove(slider));
AddAssert("slider removed", () => EditorBeatmap.HitObjects.Count == 0);
addUndoSteps();
AddAssert("slider not removed", () => EditorBeatmap.HitObjects.Count == 1);
AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType));
}
private void addUndoSteps() => AddStep("undo", () => Editor.Undo());
}
}

View File

@ -299,6 +299,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
assertControlPointTypeDuringPlacement(0, PathType.BSpline(4));
AddStep("press alt-2", () =>
{
InputManager.PressKey(Key.AltLeft);
InputManager.Key(Key.Number2);
InputManager.ReleaseKey(Key.AltLeft);
});
assertControlPointTypeDuringPlacement(0, PathType.BEZIER);
AddStep("start new segment via S", () => InputManager.Key(Key.S));
assertControlPointTypeDuringPlacement(2, PathType.LINEAR);
@ -309,7 +317,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
addClickStep(MouseButton.Right);
assertPlaced(true);
assertFinalControlPointType(0, PathType.BSpline(4));
assertFinalControlPointType(0, PathType.BEZIER);
assertFinalControlPointType(2, PathType.PERFECT_CURVE);
}
@ -506,6 +514,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private Slider? getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
}
}

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
@ -22,9 +20,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene
{
private Slider slider;
private DrawableSlider drawableObject;
private TestSliderBlueprint blueprint;
private Slider slider = null!;
private DrawableSlider drawableObject = null!;
private TestSliderBlueprint blueprint = null!;
[SetUp]
public void Setup() => Schedule(() =>
@ -163,6 +161,44 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
checkControlPointSelected(1, false);
}
[Test]
public void TestAdjustLength()
{
AddStep("move mouse to drag marker", () =>
{
Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0);
InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
});
AddStep("start drag", () => InputManager.PressButton(MouseButton.Left));
AddStep("move mouse to control point 1", () =>
{
Vector2 position = slider.Position + slider.Path.ControlPoints[1].Position + new Vector2(60, 0);
InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
});
AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left));
AddAssert("expected distance halved",
() => Precision.AlmostEquals(slider.Path.Distance, 172.2, 0.1));
AddStep("move mouse to drag marker", () =>
{
Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0);
InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
});
AddStep("start drag", () => InputManager.PressButton(MouseButton.Left));
AddStep("move mouse beyond last control point", () =>
{
Vector2 position = slider.Position + slider.Path.ControlPoints[2].Position + new Vector2(100, 0);
InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
});
AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left));
AddAssert("expected distance is calculated distance",
() => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1));
moveMouseToControlPoint(1);
AddAssert("expected distance is unchanged",
() => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1));
}
private void moveHitObject()
{
AddStep("move hitobject", () =>
@ -180,6 +216,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("tail positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre));
AddAssert("end drag marker positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.EndDragMarker!.ToScreenSpace(blueprint.TailOverlay.EndDragMarker.OriginPosition), drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre, 2));
}
private void moveMouseToControlPoint(int index)
@ -192,14 +231,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}
private void checkControlPointSelected(int index, bool selected)
=> AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected);
=> AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser!.Pieces[index].IsSelected.Value == selected);
private partial class TestSliderBlueprint : SliderSelectionBlueprint
{
public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
public new PathControlPointVisualiser<Slider> ControlPointVisualiser => base.ControlPointVisualiser;
public new PathControlPointVisualiser<Slider>? ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(Slider slider)
: base(slider)

View File

@ -15,6 +15,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
}
}

View File

@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.710442985146793d, 239, "diffcalc-test")]
[TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(0.14102693012101306d, 2, "nan-slider")]
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9742952703071666d, 239, "diffcalc-test")]
[TestCase(1.743180218215227d, 54, "zero-length-sliders")]
[TestCase(0.55071082800473514d, 4, "very-fast-slider")]
[TestCase(8.9825709931204205d, 239, "diffcalc-test")]
[TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
[TestCase(0.55231632896800109d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.710442985146793d, 239, "diffcalc-test")]
[TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -88,6 +88,21 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("trail is disjoint", () => this.ChildrenOfType<LegacyCursorTrail>().Single().DisjointTrail, () => Is.True);
}
[Test]
public void TestClickExpand()
{
createTest(() => new Container
{
RelativeSizeAxes = Axes.Both,
Scale = new Vector2(10),
Child = new CursorTrail(),
});
AddStep("expand", () => this.ChildrenOfType<CursorTrail>().Single().NewPartScale = new Vector2(3));
AddWaitStep("let the cursor trail draw a bit", 5);
AddStep("contract", () => this.ChildrenOfType<CursorTrail>().Single().NewPartScale = Vector2.One);
}
private void createTest(Func<Drawable> createContent) => AddStep("create trail", () =>
{
Clear();

View File

@ -0,0 +1,133 @@
// 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.Graphics;
using osu.Framework.Testing;
using osu.Game.Replays;
using osu.Game.Rulesets.Osu.Configuration;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneOsuAnalysisContainer : OsuTestScene
{
private TestReplayAnalysisOverlay analysisContainer = null!;
private ReplayAnalysisSettings settings = null!;
[Cached]
private OsuRulesetConfigManager config = new OsuRulesetConfigManager(null, new OsuRuleset().RulesetInfo);
[SetUpSteps]
public void SetUpSteps()
{
AddStep("create analysis container", () =>
{
Children = new Drawable[]
{
new OsuPlayfieldAdjustmentContainer
{
Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()),
},
settings = new ReplayAnalysisSettings(config),
};
settings.ShowClickMarkers.Value = false;
settings.ShowAimMarkers.Value = false;
settings.ShowCursorPath.Value = false;
});
}
[Test]
public void TestEverythingOn()
{
AddStep("enable everything", () =>
{
settings.ShowClickMarkers.Value = true;
settings.ShowAimMarkers.Value = true;
settings.ShowCursorPath.Value = true;
});
}
[Test]
public void TestHitMarkers()
{
AddStep("enable hit markers", () => settings.ShowClickMarkers.Value = true);
AddUntilStep("hit markers visible", () => analysisContainer.HitMarkersVisible);
AddStep("disable hit markers", () => settings.ShowClickMarkers.Value = false);
AddUntilStep("hit markers not visible", () => !analysisContainer.HitMarkersVisible);
}
[Test]
public void TestAimMarker()
{
AddStep("enable aim markers", () => settings.ShowAimMarkers.Value = true);
AddUntilStep("aim markers visible", () => analysisContainer.AimMarkersVisible);
AddStep("disable aim markers", () => settings.ShowAimMarkers.Value = false);
AddUntilStep("aim markers not visible", () => !analysisContainer.AimMarkersVisible);
}
[Test]
public void TestAimLines()
{
AddStep("enable aim lines", () => settings.ShowCursorPath.Value = true);
AddUntilStep("aim lines visible", () => analysisContainer.AimLinesVisible);
AddStep("disable aim lines", () => settings.ShowCursorPath.Value = false);
AddUntilStep("aim lines not visible", () => !analysisContainer.AimLinesVisible);
}
private Replay fabricateReplay()
{
var frames = new List<ReplayFrame>();
var random = new Random();
int posX = 250;
int posY = 250;
var actions = new HashSet<OsuAction>();
for (int i = 0; i < 1000; i++)
{
posX = Math.Clamp(posX + random.Next(-20, 21), -100, 600);
posY = Math.Clamp(posY + random.Next(-20, 21), -100, 600);
if (random.NextDouble() > (actions.Count == 0 ? 0.9 : 0.95))
{
actions.Add(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton);
}
else if (random.NextDouble() > 0.7)
{
actions.Remove(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton);
}
frames.Add(new OsuReplayFrame
{
Time = Time.Current + i * 15,
Position = new Vector2(posX, posY),
Actions = actions.ToList(),
});
}
return new Replay { Frames = frames };
}
private partial class TestReplayAnalysisOverlay : ReplayAnalysisOverlay
{
public TestReplayAnalysisOverlay(Replay replay)
: base(replay)
{
}
public bool HitMarkersVisible => ClickMarkers?.Alpha > 0 && ClickMarkers.Entries.Any();
public bool AimMarkersVisible => FrameMarkers?.Alpha > 0 && FrameMarkers.Entries.Any();
public bool AimLinesVisible => CursorPath?.Alpha > 0 && CursorPath.Vertices.Count > 1;
}
}
}

View File

@ -42,7 +42,12 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
{
base.PostProcess();
var hitObjects = Beatmap.HitObjects as List<OsuHitObject> ?? Beatmap.HitObjects.OfType<OsuHitObject>().ToList();
ApplyStacking(Beatmap);
}
internal static void ApplyStacking(IBeatmap beatmap)
{
var hitObjects = beatmap.HitObjects as List<OsuHitObject> ?? beatmap.HitObjects.OfType<OsuHitObject>().ToList();
if (hitObjects.Count > 0)
{
@ -50,14 +55,14 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
foreach (var h in hitObjects)
h.StackHeight = 0;
if (Beatmap.BeatmapInfo.BeatmapVersion >= 6)
applyStacking(Beatmap.BeatmapInfo, hitObjects, 0, hitObjects.Count - 1);
if (beatmap.BeatmapInfo.BeatmapVersion >= 6)
applyStacking(beatmap.BeatmapInfo, hitObjects, 0, hitObjects.Count - 1);
else
applyStackingOld(Beatmap.BeatmapInfo, hitObjects);
applyStackingOld(beatmap.BeatmapInfo, hitObjects);
}
}
private void applyStacking(BeatmapInfo beatmapInfo, List<OsuHitObject> hitObjects, int startIndex, int endIndex)
private static void applyStacking(BeatmapInfo beatmapInfo, List<OsuHitObject> hitObjects, int startIndex, int endIndex)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(startIndex, endIndex);
ArgumentOutOfRangeException.ThrowIfNegative(startIndex);
@ -209,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
}
}
private void applyStackingOld(BeatmapInfo beatmapInfo, List<OsuHitObject> hitObjects)
private static void applyStackingOld(BeatmapInfo beatmapInfo, List<OsuHitObject> hitObjects)
{
for (int i = 0; i < hitObjects.Count; i++)
{

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Game.Configuration;
using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.UI;
@ -11,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Configuration
{
public class OsuRulesetConfigManager : RulesetConfigManager<OsuRulesetSetting>
{
public OsuRulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null)
public OsuRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null)
: base(settings, ruleset, variant)
{
}
@ -24,6 +22,12 @@ namespace osu.Game.Rulesets.Osu.Configuration
SetDefault(OsuRulesetSetting.ShowCursorTrail, true);
SetDefault(OsuRulesetSetting.ShowCursorRipples, false);
SetDefault(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None);
SetDefault(OsuRulesetSetting.ReplayClickMarkersEnabled, false);
SetDefault(OsuRulesetSetting.ReplayFrameMarkersEnabled, false);
SetDefault(OsuRulesetSetting.ReplayCursorPathEnabled, false);
SetDefault(OsuRulesetSetting.ReplayCursorHideEnabled, false);
SetDefault(OsuRulesetSetting.ReplayAnalysisDisplayLength, 800);
}
}
@ -34,5 +38,12 @@ namespace osu.Game.Rulesets.Osu.Configuration
ShowCursorTrail,
ShowCursorRipples,
PlayfieldBorderStyle,
// Replay
ReplayClickMarkersEnabled,
ReplayFrameMarkersEnabled,
ReplayCursorPathEnabled,
ReplayCursorHideEnabled,
ReplayAnalysisDisplayLength,
}
}

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@ -10,8 +12,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class RhythmEvaluator
{
private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max.
private const double rhythm_multiplier = 0.75;
private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95;
private const double rhythm_ratio_multiplier = 12.0;
/// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@ -21,15 +25,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner)
return 0;
int previousIslandSize = 0;
double rhythmComplexitySum = 0;
int islandSize = 1;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
var island = new Island(deltaDifferenceEpsilon);
var previousIsland = new Island(deltaDifferenceEpsilon);
// we can't use dictionary here because we need to compare island with a tolerance
// which is impossible to pass into the hash comparer
var islandCounts = new List<(Island Island, int Count)>();
double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms
bool firstDeltaSwitch = false;
int historicalNoteCount = Math.Min(current.Index, 32);
int historicalNoteCount = Math.Min(current.Index, history_objects_max);
int rhythmStart = 0;
@ -39,74 +50,177 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart);
OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1);
// we go from the furthest object back to the current one
for (int i = rhythmStart; i > 0; i--)
{
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);
double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now
// scales note 0 to 1 from history to now
double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount;
currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count.
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
double currDelta = currObj.StrainTime;
double prevDelta = prevObj.StrainTime;
double lastDelta = lastObj.StrainTime;
double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses.
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3));
// calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2));
windowPenalty = Math.Min(1, windowPenalty);
// reduce ratio bonus if delta difference is too big
double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta);
double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0);
double effectiveRatio = windowPenalty * currRatio;
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = windowPenalty * currRatio * fractionMultiplier;
if (firstDeltaSwitch)
{
if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta))
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
if (islandSize < 7)
islandSize++; // island is still progressing, count size.
// island is still progressing
island.AddDelta((int)currDelta);
}
else
{
if (currObj.BaseObject is Slider) // bpm change is into slider, this is easy acc window
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.125;
if (prevObj.BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle
effectiveRatio *= 0.25;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.3;
if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet)
effectiveRatio *= 0.25;
// repeated island polarity (2 -> 4, 3 -> 5)
if (island.IsSimilarPolarity(previousIsland))
effectiveRatio *= 0.5;
if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5)
effectiveRatio *= 0.50;
if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
// previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon)
effectiveRatio *= 0.125;
rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2;
// repeated island size (ex: triplet -> triplet)
// TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
if (previousIsland.DeltaCount == island.DeltaCount)
effectiveRatio *= 0.5;
var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));
if (islandCount != default)
{
int countIndex = islandCounts.IndexOf(islandCount);
// only add island to island counts if they're going one after another
if (previousIsland.Equals(island))
islandCount.Count++;
// repeated island (ex: triplet -> triplet)
double power = logistic(island.Delta, 2.75, 0.24, 14);
effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power));
islandCounts[countIndex] = (islandCount.Island, islandCount.Count);
}
else
{
islandCounts.Add((island, 1));
}
// scale down the difficulty if the object is doubletappable
double doubletapness = prevObj.GetDoubletapness(currObj);
effectiveRatio *= 1 - doubletapness * 0.75;
rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay;
startRatio = effectiveRatio;
previousIslandSize = islandSize; // log the last island size.
previousIsland = island;
if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting
firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.
if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting
firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.
islandSize = 1;
island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
}
else if (prevDelta > 1.25 * currDelta) // we want to be speeding up.
else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up
{
// Begin counting island until we change speed again.
firstDeltaSwitch = true;
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.6;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.6;
startRatio = effectiveRatio;
islandSize = 1;
island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
lastObj = prevObj;
prevObj = currObj;
}
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though)
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
}
private static double logistic(double x, double maxValue, double multiplier, double offset) => (maxValue / (1 + Math.Pow(Math.E, offset - (multiplier * x))));
private class Island : IEquatable<Island>
{
private readonly double deltaDifferenceEpsilon;
public Island(double epsilon)
{
deltaDifferenceEpsilon = epsilon;
}
public Island(int delta, double epsilon)
{
deltaDifferenceEpsilon = epsilon;
Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
DeltaCount++;
}
public int Delta { get; private set; } = int.MaxValue;
public int DeltaCount { get; private set; }
public void AddDelta(int delta)
{
if (Delta == int.MaxValue)
Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
DeltaCount++;
}
public bool IsSimilarPolarity(Island other)
{
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
return DeltaCount % 2 == other.DeltaCount % 2;
}
public bool Equals(Island? other)
{
if (other == null)
return false;
return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
DeltaCount == other.DeltaCount;
}
public override string ToString()
{
return $"{Delta}x{DeltaCount}";
}
}
}
}

View File

@ -10,9 +10,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class SpeedEvaluator
{
private const double single_spacing_threshold = 125;
private const double single_spacing_threshold = 125; // 1.25 circles distance between centers
private const double min_speed_bonus = 75; // ~200BPM
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.94;
/// <summary>
/// Evaluates the difficulty of tapping the current object, based on:
@ -30,36 +31,35 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
var osuNextObj = (OsuDifficultyHitObject?)current.Next(0);
double strainTime = osuCurrObj.StrainTime;
double doubletapness = 1;
// Nerf doubletappable doubles.
if (osuNextObj != null)
{
double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2);
doubletapness = Math.Pow(speedRatio, 1 - windowRatio);
}
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1);
// derive speedBonus for calculation
double speedBonus = 1.0;
// speedBonus will be 0.0 for BPM < 200
double speedBonus = 0.0;
// Add additional scaling bonus for streams/bursts higher than 200bpm
if (strainTime < min_speed_bonus)
speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
speedBonus = 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance);
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;
return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) * doubletapness / strainTime;
// Cap distance at single_spacing_threshold
distance = Math.Min(distance, single_spacing_threshold);
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
// Apply penalty if there's doubletappable doubles
return difficulty * doubletapness;
}
}
}

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