1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 05:37:25 +08:00

Merge branch 'master' into beatmap-info-purge

This commit is contained in:
Dean Herbert 2024-09-15 04:52:48 +09:00
commit 58fe502af4
No known key found for this signature in database
450 changed files with 12203 additions and 4009 deletions

View File

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

View File

@ -64,7 +64,8 @@ jobs:
matrix:
os:
- { prettyname: Windows, fullname: windows-latest }
- { prettyname: macOS, fullname: macos-latest }
# macOS runner performance has gotten unbearably slow so let's turn them off temporarily.
# - { prettyname: macOS, fullname: macos-latest }
- { prettyname: Linux, fullname: ubuntu-latest }
threadingMode: ['SingleThread', 'MultiThreaded']
timeout-minutes: 120
@ -120,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
@ -136,8 +135,5 @@ jobs:
- 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
- name: Build
run: dotnet build -c Debug osu.iOS

View File

@ -55,7 +55,7 @@ When in doubt, it's probably best to start with a discussion first. We will esca
While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change.
The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience.
The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good first issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience.
In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive.

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.912.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

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

@ -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,139 @@
// 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 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(UpdateProgressNotification? notification = null)
{
// 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 (localUserInfo?.IsPlaying.Value == true)
{
scheduleRecheck = true;
return false;
}
// 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 = () =>
{
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.
if (notification == null)
{
notification = new UpdateProgressNotification
{
CompletionClickAction = restartToApplyUpdate,
};
Schedule(() => notificationOverlay.Post(notification));
}
notification.StartDownload();
try
{
await updateManager.DownloadUpdatesAsync(pendingUpdate, p => notification.Progress = p / 100f).ConfigureAwait(false);
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 bool restartToApplyUpdate()
{
// TODO: Migrate this to async flow whenever available (see https://github.com/ppy/osu/pull/28743#discussion_r1740505665).
// Currently there's an internal Thread.Sleep(300) which will cause a stutter when the user clicks to restart.
updateManager.WaitExitThenApplyUpdates(pendingUpdate?.TargetFullRelease);
Schedule(() => game.AttemptExit());
return true;
}
}
}

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="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.598-g933b2ab" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />

View File

@ -82,6 +82,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddMouseMoveStep(-100, 100);
addVertexCheckStep(3, 1, times[0], positions[0]);
addDragEndStep();
}
[Test]
@ -100,6 +101,9 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddMouseMoveStep(times[2] - 50, positions[2] - 50);
addVertexCheckStep(4, 1, times[1] - 50, positions[1] - 50);
addVertexCheckStep(4, 2, times[2] - 50, positions[2] - 50);
AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft));
addDragEndStep();
}
[Test]
@ -113,6 +117,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 400);
AddAssert("slider velocity changed", () => !hitObject.SliderVelocityMultiplierBindable.IsDefault);
addDragEndStep();
}
[Test]
@ -129,6 +134,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddStep("scroll playfield", () => manualClock.CurrentTime += 200);
AddMouseMoveStep(times[1] + 200, positions[1] + 100);
addVertexCheckStep(2, 1, times[1] + 200, positions[1] + 100);
addDragEndStep();
}
[Test]
@ -161,18 +167,18 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addAddVertexSteps(500, 150);
addVertexCheckStep(3, 1, 500, 150);
addAddVertexSteps(90, 200);
addVertexCheckStep(4, 1, times[0], positions[0]);
addAddVertexSteps(160, 200);
addVertexCheckStep(4, 1, 160, 200);
addAddVertexSteps(750, 180);
addVertexCheckStep(5, 4, 750, 180);
addVertexCheckStep(5, 4, 800, 160);
AddAssert("duration is changed", () => Precision.AlmostEquals(hitObject.Duration, 800 - times[0], 1e-3));
}
[Test]
public void TestDeleteVertex()
{
double[] times = { 100, 300, 500 };
double[] times = { 100, 300, 400 };
float[] positions = { 100, 200, 150 };
addBlueprintStep(times, positions);
@ -265,7 +271,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddStep("delete vertex", () =>
{
InputManager.PressKey(Key.ShiftLeft);
InputManager.Click(MouseButton.Left);
InputManager.Click(MouseButton.Right);
InputManager.ReleaseKey(Key.ShiftLeft);
});
}

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

@ -254,5 +254,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

@ -20,7 +20,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,7 +41,7 @@ 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)),

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

@ -13,6 +13,7 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
@ -42,6 +43,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
[Resolved]
private IBeatSnapProvider? beatSnapProvider { get; set; }
[Resolved]
protected EditorBeatmap? EditorBeatmap { get; private set; }
protected EditablePath(Func<float, double> positionToTime)
{
PositionToTime = positionToTime;
@ -103,15 +107,23 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
//
// The value is clamped here by the bindable min and max values.
// In case the required velocity is too large, the path is not preserved.
double previousVelocity = svBindable.Value;
svBindable.Value = Math.Ceiling(requiredVelocity / svToVelocityFactor);
path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY, hitObject.Velocity);
// adjust velocity locally, so that once the SV change is applied by applying defaults
// (triggered by `EditorBeatmap.Update()` call at end of method),
// it results in the outcome desired by the user.
double relativeChange = svBindable.Value / previousVelocity;
double localVelocity = hitObject.Velocity * relativeChange;
path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY, localVelocity);
if (beatSnapProvider == null) return;
double endTime = hitObject.StartTime + path.Duration;
double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime);
hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity;
hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * localVelocity;
EditorBeatmap?.Update(hitObject);
}
public Vector2 ToRelativePosition(Vector2 screenSpacePosition)

View File

@ -4,12 +4,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Screens.Edit;
using osu.Game.Rulesets.Catch.Objects;
using osuTK;
using osuTK.Input;
@ -19,22 +18,27 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public MenuItem[] ContextMenuItems => getContextMenuItems().ToArray();
private readonly JuiceStream juiceStream;
// To handle when the editor is scrolled while dragging.
private Vector2 dragStartPosition;
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
public SelectionEditablePath(Func<float, double> positionToTime)
public SelectionEditablePath(JuiceStream juiceStream, Func<float, double> positionToTime)
: base(positionToTime)
{
this.juiceStream = juiceStream;
}
public void AddVertex(Vector2 relativePosition)
{
EditorBeatmap?.BeginChange();
double time = Math.Max(0, PositionToTime(relativePosition.Y));
int index = AddVertex(time, relativePosition.X);
UpdateHitObjectFromPath(juiceStream);
selectOnly(index);
EditorBeatmap?.EndChange();
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => InternalChildren.Any(d => d.ReceivePositionalInputAt(screenSpacePos));
@ -45,9 +49,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
if (index == -1 || VertexStates[index].IsFixed)
return false;
if (e.Button == MouseButton.Left && e.ShiftPressed)
if (e.Button == MouseButton.Right && e.ShiftPressed)
{
EditorBeatmap?.BeginChange();
RemoveVertex(index);
UpdateHitObjectFromPath(juiceStream);
EditorBeatmap?.EndChange();
return true;
}
@ -74,7 +82,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
for (int i = 0; i < VertexCount; i++)
VertexStates[i].VertexBeforeChange = Vertices[i];
changeHandler?.BeginChange();
EditorBeatmap?.BeginChange();
return true;
}
@ -88,7 +96,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
protected override void OnDragEnd(DragEndEvent e)
{
changeHandler?.EndChange();
EditorBeatmap?.EndChange();
}
private int getMouseTargetVertex(Vector2 screenSpacePosition)
@ -118,11 +126,17 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
private void deleteSelectedVertices()
{
EditorBeatmap?.BeginChange();
for (int i = VertexCount - 1; i >= 0; i--)
{
if (VertexStates[i].IsSelected)
RemoveVertex(i);
}
UpdateHitObjectFromPath(juiceStream);
EditorBeatmap?.EndChange();
}
}
}

View File

@ -5,6 +5,7 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osuTK;
@ -12,6 +13,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
public partial class VertexPiece : Circle
{
private VertexState state = new VertexState();
[Resolved]
private OsuColour osuColour { get; set; } = null!;
@ -24,7 +27,32 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void UpdateFrom(VertexState state)
{
Colour = state.IsSelected ? osuColour.Yellow.Lighten(1) : osuColour.Yellow;
this.state = state;
updateMarkerDisplay();
}
protected override bool OnHover(HoverEvent e)
{
updateMarkerDisplay();
return false;
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateMarkerDisplay();
}
/// <summary>
/// Updates the state of the circular control point marker.
/// </summary>
private void updateMarkerDisplay()
{
var colour = osuColour.Yellow;
if (IsHovered || state.IsSelected)
colour = colour.Lighten(1);
Colour = colour;
Alpha = state.IsFixed ? 0.5f : 1;
}
}

View File

@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new SelectionEditablePath(positionToTime)
editablePath = new SelectionEditablePath(hitObject, positionToTime)
};
}

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

@ -15,7 +15,7 @@ using osuTK;
namespace osu.Game.Rulesets.Catch.Objects
{
public abstract class CatchHitObject : HitObject, IHasPosition, IHasComboInformation
public abstract class CatchHitObject : HitObject, IHasPosition, IHasComboInformation, IHasTimePreempt
{
public const float OBJECT_RADIUS = 64;

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

@ -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

@ -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,7 +24,7 @@ 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;
@ -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

@ -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

@ -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

@ -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

@ -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);
}

View File

@ -163,6 +163,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", () =>

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, hitObjects, 0, hitObjects.Count - 1);
if (beatmap.BeatmapInfo.BeatmapVersion >= 6)
applyStacking(beatmap, hitObjects, 0, hitObjects.Count - 1);
else
applyStackingOld(Beatmap, hitObjects);
applyStackingOld(beatmap, hitObjects);
}
}
private void applyStacking(IBeatmap beatmap, List<OsuHitObject> hitObjects, int startIndex, int endIndex)
private static void applyStacking(IBeatmap beatmap, 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(IBeatmap beatmap, List<OsuHitObject> hitObjects)
private static void applyStackingOld(IBeatmap beatmap, 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

@ -10,7 +10,7 @@ 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;
@ -50,16 +50,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// 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
// speedBonus will be 1.0 for BPM < 200
double speedBonus = 1.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);
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 2 at single_spacing_threshold
double distanceBonus = 1 + Math.Pow(distance / single_spacing_threshold, 3.5);
// Base difficulty with all bonuses
double difficulty = speedBonus * distanceBonus * 1000 / strainTime;
// Apply penalty if there's doubletappable doubles
return difficulty * doubletapness;
}
}
}

View File

@ -61,12 +61,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
flashlightRating *= 0.7;
}
double baseAimPerformance = Math.Pow(5 * Math.Max(1, aimRating / 0.0675) - 4, 3) / 100000;
double baseSpeedPerformance = Math.Pow(5 * Math.Max(1, speedRating / 0.0675) - 4, 3) / 100000;
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
double baseFlashlightPerformance = 0.0;
if (mods.Any(h => h is OsuModFlashlight))
baseFlashlightPerformance = Math.Pow(flashlightRating, 2.0) * 25.0;
baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
double basePerformance =
Math.Pow(

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@ -86,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
double aimValue = Math.Pow(5.0 * Math.Max(1.0, attributes.AimDifficulty / 0.0675) - 4.0, 3.0) / 100000.0;
double aimValue = OsuStrainSkill.DifficultyToPerformance(attributes.AimDifficulty);
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
@ -139,7 +140,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax))
return 0.0;
double speedValue = Math.Pow(5.0 * Math.Max(1.0, attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0;
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
@ -226,7 +227,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (!score.Mods.Any(h => h is OsuModFlashlight))
return 0.0;
double flashlightValue = Math.Pow(attributes.FlashlightDifficulty, 2.0) * 25.0;
double flashlightValue = Flashlight.DifficultyToPerformance(attributes.FlashlightDifficulty);
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)

View File

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

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
hasHiddenMod = mods.Any(m => m is OsuModHidden);
}
private double skillMultiplier => 0.052;
private double skillMultiplier => 0.05512;
private double strainDecayBase => 0.15;
private double currentStrain;
@ -41,6 +41,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return currentStrain;
}
public override double DifficultyValue() => GetCurrentStrainPeaks().Sum() * OsuStrainSkill.DEFAULT_DIFFICULTY_MULTIPLIER;
public override double DifficultyValue() => GetCurrentStrainPeaks().Sum();
public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2);
}
}

View File

@ -12,12 +12,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
public abstract class OsuStrainSkill : StrainSkill
{
/// <summary>
/// The default multiplier applied by <see cref="OsuStrainSkill"/> to the final difficulty value after all other calculations.
/// May be overridden via <see cref="DifficultyMultiplier"/>.
/// </summary>
public const double DEFAULT_DIFFICULTY_MULTIPLIER = 1.06;
/// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to.
/// This is done in order to decrease their impact on the overall difficulty of the map for this skill.
@ -29,11 +23,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
protected virtual double ReducedStrainBaseline => 0.75;
/// <summary>
/// The final multiplier to be applied to <see cref="DifficultyValue"/> after all other calculations.
/// </summary>
protected virtual double DifficultyMultiplier => DEFAULT_DIFFICULTY_MULTIPLIER;
protected OsuStrainSkill(Mod[] mods)
: base(mods)
{
@ -65,7 +54,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
weight *= DecayWeight;
}
return difficulty * DifficultyMultiplier;
return difficulty;
}
public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0;
}
}

View File

@ -16,14 +16,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
public class Speed : OsuStrainSkill
{
private double skillMultiplier => 1375;
private double skillMultiplier => 1.430;
private double strainDecayBase => 0.3;
private double currentStrain;
private double currentRhythm;
protected override int ReducedSectionCount => 5;
protected override double DifficultyMultiplier => 1.04;
private readonly List<double> objectStrains = new List<double>();

View File

@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public readonly PathControlPoint ControlPoint;
private readonly T hitObject;
private readonly Circle circle;
private readonly FastCircle circle;
private readonly Drawable markerRing;
[Resolved]
@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
InternalChildren = new[]
{
circle = new Circle
circle = new FastCircle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -309,8 +309,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (!e.AltPressed)
return false;
// If no pieces are selected, we can't change the path type.
if (Pieces.All(p => !p.IsSelected.Value))
return false;
var type = path_types[e.Key - Key.Number1];
// The first control point can never be inherit type
if (Pieces[0].IsSelected.Value && type == null)
return false;

View File

@ -1,7 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
using osu.Game.Rulesets.Osu.Objects;
@ -9,11 +12,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
public partial class SliderCircleOverlay : CompositeDrawable
{
public SliderEndDragMarker? EndDragMarker { get; }
public RectangleF VisibleQuad
{
get
{
var result = CirclePiece.ScreenSpaceDrawQuad.AABBFloat;
if (endDragMarkerContainer == null) return result;
var size = result.Size * 1.4f;
var location = result.TopLeft - result.Size * 0.2f;
return new RectangleF(location, size);
}
}
protected readonly HitCirclePiece CirclePiece;
private readonly Slider slider;
private readonly SliderPosition position;
private readonly HitCircleOverlapMarker? marker;
private readonly Container? endDragMarkerContainer;
public SliderCircleOverlay(Slider slider, SliderPosition position)
{
@ -24,26 +44,49 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
AddInternal(marker = new HitCircleOverlapMarker());
AddInternal(CirclePiece = new HitCirclePiece());
if (position == SliderPosition.End)
{
AddInternal(endDragMarkerContainer = new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Padding = new MarginPadding(-2.5f),
Child = EndDragMarker = new SliderEndDragMarker()
});
}
}
protected override void Update()
{
base.Update();
var circle = position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : slider.TailCircle;
var circle = position == SliderPosition.Start ? (HitCircle)slider.HeadCircle :
slider.RepeatCount % 2 == 0 ? slider.TailCircle : slider.LastRepeat!;
CirclePiece.UpdateFrom(circle);
marker?.UpdateFrom(circle);
if (endDragMarkerContainer != null)
{
endDragMarkerContainer.Position = circle.Position;
endDragMarkerContainer.Scale = CirclePiece.Scale * 1.2f;
var diff = slider.Path.PositionAt(1) - slider.Path.PositionAt(0.99f);
endDragMarkerContainer.Rotation = float.RadiansToDegrees(MathF.Atan2(diff.Y, diff.X));
}
}
public override void Hide()
{
CirclePiece.Hide();
endDragMarkerContainer?.Hide();
}
public override void Show()
{
CirclePiece.Show();
endDragMarkerContainer?.Show();
}
}
}

View File

@ -0,0 +1,84 @@
// 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.Graphics.Lines;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
public partial class SliderEndDragMarker : SmoothPath
{
public Action<DragStartEvent>? StartDrag { get; set; }
public Action<DragEvent>? Drag { get; set; }
public Action? EndDrag { get; set; }
[Resolved]
private OsuColour colours { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
var path = PathApproximator.CircularArcToPiecewiseLinear([
new Vector2(0, OsuHitObject.OBJECT_RADIUS),
new Vector2(OsuHitObject.OBJECT_RADIUS, 0),
new Vector2(0, -OsuHitObject.OBJECT_RADIUS)
]);
Anchor = Anchor.CentreLeft;
Origin = Anchor.CentreLeft;
PathRadius = 5;
Vertices = path;
}
protected override void LoadComplete()
{
base.LoadComplete();
updateState();
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
protected override bool OnDragStart(DragStartEvent e)
{
updateState();
StartDrag?.Invoke(e);
return true;
}
protected override void OnDrag(DragEvent e)
{
updateState();
base.OnDrag(e);
Drag?.Invoke(e);
}
protected override void OnDragEnd(DragEndEvent e)
{
updateState();
EndDrag?.Invoke();
base.OnDragEnd(e);
}
private void updateState()
{
Colour = IsHovered || IsDragged ? colours.Red : colours.Yellow;
}
}
}

View File

@ -1,13 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Input;
@ -29,30 +25,29 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
public new Slider HitObject => (Slider)base.HitObject;
private SliderBodyPiece bodyPiece;
private HitCirclePiece headCirclePiece;
private HitCirclePiece tailCirclePiece;
private PathControlPointVisualiser<Slider> controlPointVisualiser;
private SliderBodyPiece bodyPiece = null!;
private HitCirclePiece headCirclePiece = null!;
private HitCirclePiece tailCirclePiece = null!;
private PathControlPointVisualiser<Slider> controlPointVisualiser = null!;
private InputManager inputManager;
private InputManager inputManager = null!;
private PathControlPoint? cursor;
private SliderPlacementState state;
private PathControlPoint segmentStart;
private PathControlPoint cursor;
private int currentSegmentLength;
private bool usingCustomSegmentType;
[Resolved(CanBeNull = true)]
[CanBeNull]
private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved]
private IPositionSnapProvider? positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
[CanBeNull]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
[Resolved]
private IDistanceSnapProvider? distanceSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
[CanBeNull]
private FreehandSliderToolboxGroup freehandToolboxGroup { get; set; }
[Resolved]
private FreehandSliderToolboxGroup? freehandToolboxGroup { get; set; }
private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 };
@ -84,7 +79,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
inputManager = GetContainingInputManager()!;
if (freehandToolboxGroup != null)
{
@ -108,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
[Resolved]
private EditorBeatmap editorBeatmap { get; set; }
private EditorBeatmap editorBeatmap { get; set; } = null!;
public override void UpdateTimeAndPosition(SnapResult result)
{
@ -151,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
case SliderPlacementState.ControlPoints:
if (canPlaceNewControlPoint(out var lastPoint))
placeNewControlPoint();
else
else if (lastPoint != null)
beginNewSegment(lastPoint);
break;
@ -162,9 +158,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void beginNewSegment(PathControlPoint lastPoint)
{
// Transform the last point into a new segment.
Debug.Assert(lastPoint != null);
segmentStart = lastPoint;
segmentStart.Type = PathType.LINEAR;
@ -359,8 +352,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
// Update the cursor position.
var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.ControlPoints ? SnapType.GlobalGrids : SnapType.All);
cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position;
cursor.Position = getCursorPosition();
}
else if (cursor != null)
{
@ -374,19 +366,27 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
}
private Vector2 getCursorPosition()
{
var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.ControlPoints ? SnapType.GlobalGrids : SnapType.All);
return ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position;
}
/// <summary>
/// Whether a new control point can be placed at the current mouse position.
/// </summary>
/// <param name="lastPoint">The last-placed control point. May be null, but is not null if <c>false</c> is returned.</param>
/// <returns>Whether a new control point can be placed at the current position.</returns>
private bool canPlaceNewControlPoint([CanBeNull] out PathControlPoint lastPoint)
private bool canPlaceNewControlPoint(out PathControlPoint? lastPoint)
{
// We cannot rely on the ordering of drawable pieces, so find the respective drawable piece by searching for the last non-cursor control point.
var last = HitObject.Path.ControlPoints.LastOrDefault(p => p != cursor);
var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last);
lastPoint = last;
return lastPiece.IsHovered != true;
// We may only place a new control point if the cursor is not overlapping with the last control point.
// If snapping is enabled, the cursor may not hover the last piece while still placing the control point at the same position.
return !lastPiece.IsHovered && (last is null || Vector2.DistanceSquared(last.Position, getCursorPosition()) > 1f);
}
private void placeNewControlPoint()
@ -429,7 +429,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
// Replace this segment with a circular arc if it is a reasonable substitute.
var circleArcSegment = tryCircleArc(segment);
if (circleArcSegment is not null)
if (circleArcSegment != null)
{
HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[0], PathType.PERFECT_CURVE));
HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[1]));
@ -446,7 +446,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
}
private Vector2[] tryCircleArc(List<Vector2> segment)
private Vector2[]? tryCircleArc(List<Vector2> segment)
{
if (segment.Count < 3 || freehandToolboxGroup?.CircleThreshold.Value == 0) return null;

View File

@ -1,13 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
@ -33,27 +33,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject;
protected SliderBodyPiece BodyPiece { get; private set; }
protected SliderCircleOverlay HeadOverlay { get; private set; }
protected SliderCircleOverlay TailOverlay { get; private set; }
protected SliderBodyPiece BodyPiece { get; private set; } = null!;
protected SliderCircleOverlay HeadOverlay { get; private set; } = null!;
protected SliderCircleOverlay TailOverlay { get; private set; } = null!;
[CanBeNull]
protected PathControlPointVisualiser<Slider> ControlPointVisualiser { get; private set; }
protected PathControlPointVisualiser<Slider>? ControlPointVisualiser { get; private set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
[Resolved]
private IDistanceSnapProvider? distanceSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IPlacementHandler placementHandler { get; set; }
[Resolved]
private IPlacementHandler? placementHandler { get; set; }
[Resolved(CanBeNull = true)]
private EditorBeatmap editorBeatmap { get; set; }
[Resolved]
private EditorBeatmap? editorBeatmap { get; set; }
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
[Resolved(CanBeNull = true)]
private BindableBeatDivisor beatDivisor { get; set; }
[Resolved]
private BindableBeatDivisor? beatDivisor { get; set; }
private PathControlPoint? placementControlPoint;
public override Quad SelectionQuad
{
@ -61,6 +62,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
var result = BodyPiece.ScreenSpaceDrawQuad.AABBFloat;
result = RectangleF.Union(result, HeadOverlay.VisibleQuad);
result = RectangleF.Union(result, TailOverlay.VisibleQuad);
if (ControlPointVisualiser != null)
{
foreach (var piece in ControlPointVisualiser.Pieces)
@ -76,6 +80,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private readonly BindableList<HitObject> selectedObjects = new BindableList<HitObject>();
private readonly Bindable<bool> showHitMarkers = new Bindable<bool>();
// Cached slider path which ignored the expected distance value.
private readonly Cached<SliderPath> fullPathCache = new Cached<SliderPath>();
private Vector2 lastRightClickPosition;
public SliderSelectionBlueprint(Slider slider)
: base(slider)
{
@ -91,6 +100,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End),
};
// tail will always have a non-null end drag marker.
Debug.Assert(TailOverlay.EndDragMarker != null);
TailOverlay.EndDragMarker.StartDrag += startAdjustingLength;
TailOverlay.EndDragMarker.Drag += adjustLength;
TailOverlay.EndDragMarker.EndDrag += endAdjustLength;
config.BindWith(OsuSetting.EditorShowHitMarkers, showHitMarkers);
}
@ -99,6 +115,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
base.LoadComplete();
controlPoints.BindTo(HitObject.Path.ControlPoints);
controlPoints.CollectionChanged += (_, _) => fullPathCache.Invalidate();
pathVersion.BindTo(HitObject.Path.Version);
pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject));
@ -123,7 +140,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return false;
hoveredControlPoint.IsSelected.Value = true;
ControlPointVisualiser.DeleteSelected();
ControlPointVisualiser?.DeleteSelected();
return true;
}
@ -141,7 +158,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected override bool OnHover(HoverEvent e)
{
updateVisualDefinition();
return base.OnHover(e);
}
@ -186,17 +202,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
}
private Vector2 rightClickPosition;
protected override bool OnMouseDown(MouseDownEvent e)
{
switch (e.Button)
{
case MouseButton.Right:
rightClickPosition = e.MouseDownPosition;
lastRightClickPosition = e.MouseDownPosition;
return false; // Allow right click to be handled by context menu
case MouseButton.Left:
// If there's more than two objects selected, ctrl+click should deselect
if (e.ControlPressed && IsSelected && selectedObjects.Count < 2)
{
@ -212,8 +227,134 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return false;
}
[CanBeNull]
private PathControlPoint placementControlPoint;
#region Length Adjustment (independent of path nodes)
private Vector2 lengthAdjustMouseOffset;
private double oldDuration;
private double oldVelocityMultiplier;
private double desiredDistance;
private bool isAdjustingLength;
private bool adjustVelocityMomentary;
private void startAdjustingLength(DragStartEvent e)
{
isAdjustingLength = true;
adjustVelocityMomentary = e.ShiftPressed;
lengthAdjustMouseOffset = ToLocalSpace(e.ScreenSpaceMouseDownPosition) - HitObject.Position - HitObject.Path.PositionAt(1);
oldDuration = HitObject.Path.Distance / HitObject.SliderVelocityMultiplier;
oldVelocityMultiplier = HitObject.SliderVelocityMultiplier;
changeHandler?.BeginChange();
}
private void endAdjustLength()
{
trimExcessControlPoints(HitObject.Path);
changeHandler?.EndChange();
isAdjustingLength = false;
}
private void adjustLength(MouseEvent e) => adjustLength(findClosestPathDistance(e), e.ShiftPressed);
private void adjustLength(double proposedDistance, bool adjustVelocity)
{
desiredDistance = proposedDistance;
double proposedVelocity = oldVelocityMultiplier;
if (adjustVelocity)
{
proposedVelocity = proposedDistance / oldDuration;
proposedDistance = MathHelper.Clamp(proposedDistance, 0.1 * oldDuration, 10 * oldDuration);
}
else
{
double minDistance = distanceSnapProvider?.GetBeatSnapDistanceAt(HitObject, false) * oldVelocityMultiplier ?? 1;
// Add a small amount to the proposed distance to make it easier to snap to the full length of the slider.
proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1) ?? proposedDistance;
proposedDistance = MathHelper.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance);
}
if (Precision.AlmostEquals(proposedDistance, HitObject.Path.Distance) && Precision.AlmostEquals(proposedVelocity, HitObject.SliderVelocityMultiplier))
return;
HitObject.SliderVelocityMultiplier = proposedVelocity;
HitObject.Path.ExpectedDistance.Value = proposedDistance;
editorBeatmap?.Update(HitObject);
}
/// <summary>
/// Trims control points from the end of the slider path which are not required to reach the expected end of the slider.
/// </summary>
/// <param name="sliderPath">The slider path to trim control points of.</param>
private void trimExcessControlPoints(SliderPath sliderPath)
{
if (!sliderPath.ExpectedDistance.Value.HasValue)
return;
double[] segmentEnds = sliderPath.GetSegmentEnds().ToArray();
int segmentIndex = 0;
for (int i = 1; i < sliderPath.ControlPoints.Count - 1; i++)
{
if (!sliderPath.ControlPoints[i].Type.HasValue) continue;
if (Precision.AlmostBigger(segmentEnds[segmentIndex], 1, 1E-3))
{
sliderPath.ControlPoints.RemoveRange(i + 1, sliderPath.ControlPoints.Count - i - 1);
sliderPath.ControlPoints[^1].Type = null;
break;
}
segmentIndex++;
}
}
/// <summary>
/// Finds the expected distance value for which the slider end is closest to the mouse position.
/// </summary>
private double findClosestPathDistance(MouseEvent e)
{
const double step1 = 10;
const double step2 = 0.1;
const double longer_distance_bias = 0.01;
var desiredPosition = ToLocalSpace(e.ScreenSpaceMousePosition) - HitObject.Position - lengthAdjustMouseOffset;
if (!fullPathCache.IsValid)
fullPathCache.Value = new SliderPath(HitObject.Path.ControlPoints.ToArray());
// Do a linear search to find the closest point on the path to the mouse position.
double bestValue = 0;
double minDistance = double.MaxValue;
for (double d = 0; d <= fullPathCache.Value.CalculatedDistance; d += step1)
{
double t = d / fullPathCache.Value.CalculatedDistance;
double dist = Vector2.Distance(fullPathCache.Value.PositionAt(t), desiredPosition) - d * longer_distance_bias;
if (dist >= minDistance) continue;
minDistance = dist;
bestValue = d;
}
// Do another linear search to fine-tune the result.
double maxValue = Math.Min(bestValue + step1, fullPathCache.Value.CalculatedDistance);
for (double d = bestValue - step1; d <= maxValue; d += step2)
{
double t = d / fullPathCache.Value.CalculatedDistance;
double dist = Vector2.Distance(fullPathCache.Value.PositionAt(t), desiredPosition) - d * longer_distance_bias;
if (dist >= minDistance) continue;
minDistance = dist;
bestValue = d;
}
return bestValue;
}
#endregion
protected override bool OnDragStart(DragStartEvent e)
{
@ -255,9 +396,24 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return true;
}
if (isAdjustingLength && e.ShiftPressed != adjustVelocityMomentary)
{
adjustVelocityMomentary = e.ShiftPressed;
adjustLength(desiredDistance, adjustVelocityMomentary);
return true;
}
return false;
}
protected override void OnKeyUp(KeyUpEvent e)
{
if (!IsSelected || !isAdjustingLength || e.ShiftPressed == adjustVelocityMomentary) return;
adjustVelocityMomentary = e.ShiftPressed;
adjustLength(desiredDistance, adjustVelocityMomentary);
}
private PathControlPoint addControlPoint(Vector2 position)
{
position -= HitObject.Position;
@ -326,6 +482,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void splitControlPoints(List<PathControlPoint> controlPointsToSplitAt)
{
if (editorBeatmap == null)
return;
// Arbitrary gap in milliseconds to put between split slider pieces
const double split_gap = 100;
@ -432,7 +591,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
new OsuMenuItem("Add control point", MenuItemType.Standard, () =>
{
changeHandler?.BeginChange();
addControlPoint(rightClickPosition);
addControlPoint(lastRightClickPosition);
changeHandler?.EndChange();
}),
new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream),

View File

@ -0,0 +1,54 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit.Components;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class GenerateToolboxGroup : EditorToolboxGroup
{
private readonly EditorToolButton polygonButton;
public GenerateToolboxGroup()
: base("Generate")
{
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(5),
Children = new Drawable[]
{
polygonButton = new EditorToolButton("Polygon",
() => new SpriteIcon { Icon = FontAwesome.Solid.Spinner },
() => new PolygonGenerationPopover()),
}
};
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Repeat) return false;
switch (e.Key)
{
case Key.D:
if (!e.ControlPressed || !e.ShiftPressed)
return false;
polygonButton.TriggerClick();
return true;
default:
return false;
}
}
}
}

View File

@ -54,24 +54,21 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override IEnumerable<TernaryButton> CreateTernaryButtons()
=> base.CreateTernaryButtons()
.Concat(DistanceSnapProvider.CreateTernaryButtons())
.Concat(new[]
{
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap })
});
.Append(new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap }))
.Concat(DistanceSnapProvider.CreateTernaryButtons());
private BindableList<HitObject> selectedHitObjects;
private Bindable<HitObject> placementObject;
[Cached(typeof(IDistanceSnapProvider))]
protected readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider();
public readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider();
[Cached]
protected readonly OsuGridToolboxGroup OsuGridToolboxGroup = new OsuGridToolboxGroup();
[Cached]
protected readonly FreehandSliderToolboxGroup FreehandlSliderToolboxGroup = new FreehandSliderToolboxGroup();
protected readonly FreehandSliderToolboxGroup FreehandSliderToolboxGroup = new FreehandSliderToolboxGroup();
[BackgroundDependencyLoader]
private void load()
@ -110,7 +107,8 @@ namespace osu.Game.Rulesets.Osu.Edit
RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler,
ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler,
},
FreehandlSliderToolboxGroup
new GenerateToolboxGroup(),
FreehandSliderToolboxGroup
}
);
}
@ -295,6 +293,12 @@ namespace osu.Game.Rulesets.Osu.Edit
if (Vector2.Distance(closestSnapPosition, screenSpacePosition) < snapRadius)
{
// if the snap target is a stacked object, snap to its unstacked position rather than its stacked position.
// this is intended to make working with stacks easier (because thanks to this, you can drag an object to any
// of the items on the stack to add an object to it, rather than having to drag to the position of the *first* object on it at all times).
if (b.Item is OsuHitObject osuObject && osuObject.StackOffset != Vector2.Zero)
closestSnapPosition = b.ToScreenSpace(b.ToLocalSpace(closestSnapPosition) - osuObject.StackOffset);
// only return distance portion, since time is not really valid
snapResult = new SnapResult(closestSnapPosition, null, playfield);
return true;

View File

@ -11,14 +11,14 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuHitObjectInspector : HitObjectInspector
{
protected override void AddInspectorValues()
protected override void AddInspectorValues(HitObject[] objects)
{
base.AddInspectorValues();
base.AddInspectorValues(objects);
if (EditorBeatmap.SelectedHitObjects.Count > 0)
if (objects.Length > 0)
{
var firstInSelection = (OsuHitObject)EditorBeatmap.SelectedHitObjects.MinBy(ho => ho.StartTime)!;
var lastInSelection = (OsuHitObject)EditorBeatmap.SelectedHitObjects.MaxBy(ho => ho.GetEndTime())!;
var firstInSelection = (OsuHitObject)objects.MinBy(ho => ho.StartTime)!;
var lastInSelection = (OsuHitObject)objects.MaxBy(ho => ho.GetEndTime())!;
Debug.Assert(firstInSelection != null && lastInSelection != null);

View File

@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit.Compose.Components;
@ -50,12 +51,33 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
var localDelta = this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta);
// this conditional is a rather ugly special case for stacks.
// as it turns out, adding the `EditorBeatmap.Update()` call at the end of this would cause stacked objects to jitter when moved around
// (they would stack and then unstack every frame).
// the reason for that is that the selection handling abstractions are not aware of the distinction between "displayed" and "actual" position
// which is unique to osu! due to stacking being applied as a post-processing step.
// therefore, the following loop would occur:
// - on frame 1 the blueprint is snapped to the stack's baseline position. `EditorBeatmap.Update()` applies stacking successfully,
// the blueprint moves up the stack from its original drag position.
// - on frame 2 the blueprint's position is now the *stacked* position, which is interpreted higher up as *manually performing an unstack*
// to the blueprint's unstacked position (as the machinery higher up only cares about differences in screen space position).
if (hitObjects.Any(h => Precision.AlmostEquals(localDelta, -h.StackOffset)))
return true;
// this will potentially move the selection out of bounds...
foreach (var h in hitObjects)
h.Position += this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta);
h.Position += localDelta;
// but this will be corrected.
moveSelectionInBounds();
// manually update stacking.
// this intentionally bypasses the editor `UpdateState()` / beatmap processor flow for performance reasons,
// as the entire flow is too expensive to run on every movement.
Scheduler.AddOnce(OsuBeatmapProcessor.ApplyStacking, EditorBeatmap);
return true;
}

View File

@ -0,0 +1,193 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class PolygonGenerationPopover : OsuPopover
{
private SliderWithTextBoxInput<double> distanceSnapInput = null!;
private SliderWithTextBoxInput<int> offsetAngleInput = null!;
private SliderWithTextBoxInput<int> repeatCountInput = null!;
private SliderWithTextBoxInput<int> pointInput = null!;
private RoundedButton commitButton = null!;
private readonly List<HitCircle> insertedCircles = new List<HitCircle>();
private bool began;
private bool committed;
[Resolved]
private IBeatSnapProvider beatSnapProvider { get; set; } = null!;
[Resolved]
private EditorClock editorClock { get; set; } = null!;
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
[Resolved]
private HitObjectComposer composer { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
Child = new FillFlowContainer
{
Width = 220,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(20),
Children = new Drawable[]
{
distanceSnapInput = new SliderWithTextBoxInput<double>("Distance snap:")
{
Current = new BindableNumber<double>(1)
{
MinValue = 0.1,
MaxValue = 6,
Precision = 0.1,
Value = ((OsuHitObjectComposer)composer).DistanceSnapProvider.DistanceSpacingMultiplier.Value,
},
Instantaneous = true
},
offsetAngleInput = new SliderWithTextBoxInput<int>("Offset angle:")
{
Current = new BindableNumber<int>
{
MinValue = 0,
MaxValue = 180,
Precision = 1
},
Instantaneous = true
},
repeatCountInput = new SliderWithTextBoxInput<int>("Repeats:")
{
Current = new BindableNumber<int>(1)
{
MinValue = 1,
MaxValue = 10,
Precision = 1
},
Instantaneous = true
},
pointInput = new SliderWithTextBoxInput<int>("Vertices:")
{
Current = new BindableNumber<int>(3)
{
MinValue = 3,
MaxValue = 10,
Precision = 1,
},
Instantaneous = true
},
commitButton = new RoundedButton
{
RelativeSizeAxes = Axes.X,
Text = "Create",
Action = commit
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
changeHandler?.BeginChange();
began = true;
distanceSnapInput.Current.BindValueChanged(_ => tryCreatePolygon());
offsetAngleInput.Current.BindValueChanged(_ => tryCreatePolygon());
repeatCountInput.Current.BindValueChanged(_ => tryCreatePolygon());
pointInput.Current.BindValueChanged(_ => tryCreatePolygon());
tryCreatePolygon();
}
private void tryCreatePolygon()
{
double startTime = beatSnapProvider.SnapTime(editorClock.CurrentTime);
TimingControlPoint timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(startTime);
double timeSpacing = timingPoint.BeatLength / editorBeatmap.BeatDivisor;
IHasSliderVelocity lastWithSliderVelocity = editorBeatmap.HitObjects.Where(ho => ho.GetEndTime() <= startTime).OfType<IHasSliderVelocity>().LastOrDefault() ?? new Slider();
double velocity = OsuHitObject.BASE_SCORING_DISTANCE * editorBeatmap.Difficulty.SliderMultiplier
/ LegacyRulesetExtensions.GetPrecisionAdjustedBeatLength(lastWithSliderVelocity, timingPoint, OsuRuleset.SHORT_NAME);
double length = distanceSnapInput.Current.Value * velocity * timeSpacing;
float polygonRadius = (float)(length / (2 * Math.Sin(double.Pi / pointInput.Current.Value)));
editorBeatmap.RemoveRange(insertedCircles);
insertedCircles.Clear();
var selectionHandler = (EditorSelectionHandler)composer.BlueprintContainer.SelectionHandler;
bool first = true;
for (int i = 1; i <= pointInput.Current.Value * repeatCountInput.Current.Value; ++i)
{
float angle = float.DegreesToRadians(offsetAngleInput.Current.Value) + i * (2 * float.Pi / pointInput.Current.Value);
var position = OsuPlayfield.BASE_SIZE / 2 + new Vector2(polygonRadius * float.Cos(angle), polygonRadius * float.Sin(angle));
var circle = new HitCircle
{
Position = position,
StartTime = startTime,
NewCombo = first && selectionHandler.SelectionNewComboState.Value == TernaryState.True,
};
// TODO: probably ensure samples also follow current ternary status (not trivial)
circle.Samples.Add(circle.CreateHitSampleInfo());
if (position.X < 0 || position.Y < 0 || position.X > OsuPlayfield.BASE_SIZE.X || position.Y > OsuPlayfield.BASE_SIZE.Y)
{
commitButton.Enabled.Value = false;
return;
}
insertedCircles.Add(circle);
startTime = beatSnapProvider.SnapTime(startTime + timeSpacing);
first = false;
}
editorBeatmap.AddRange(insertedCircles);
commitButton.Enabled.Value = true;
}
private void commit()
{
changeHandler?.EndChange();
committed = true;
Hide();
}
protected override void PopOut()
{
base.PopOut();
if (began && !committed)
{
editorBeatmap.RemoveRange(insertedCircles);
changeHandler?.EndChange();
}
}
}
}

View File

@ -5,19 +5,23 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public partial class DrawableOsuJudgement : DrawableJudgement
{
internal Color4 AccentColour { get; private set; }
internal SkinnableLighting Lighting { get; private set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private bool positionTransferred;
private Vector2 screenSpacePosition;
[BackgroundDependencyLoader]
private void load()
@ -32,37 +36,36 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
});
}
public override void Apply(JudgementResult result, DrawableHitObject? judgedObject)
{
base.Apply(result, judgedObject);
if (judgedObject is not DrawableOsuHitObject osuObject)
return;
AccentColour = osuObject.AccentColour.Value;
switch (osuObject)
{
case DrawableSlider slider:
screenSpacePosition = slider.TailCircle.ToScreenSpace(slider.TailCircle.OriginPosition);
break;
default:
screenSpacePosition = osuObject.ToScreenSpace(osuObject.OriginPosition);
break;
}
Scale = new Vector2(osuObject.HitObject.Scale);
}
protected override void PrepareForUse()
{
base.PrepareForUse();
Lighting.ResetAnimation();
Lighting.SetColourFrom(JudgedObject, Result);
positionTransferred = false;
}
protected override void Update()
{
base.Update();
if (!positionTransferred && JudgedObject is DrawableOsuHitObject osuObject && JudgedObject.IsInUse)
{
switch (osuObject)
{
case DrawableSlider slider:
Position = slider.TailCircle.ToSpaceOfOtherDrawable(slider.TailCircle.OriginPosition, Parent!);
break;
default:
Position = osuObject.ToSpaceOfOtherDrawable(osuObject.OriginPosition, Parent!);
break;
}
positionTransferred = true;
Scale = new Vector2(osuObject.HitObject.Scale);
}
Lighting.SetColourFrom(this, Result);
Position = Parent!.ToLocalSpace(screenSpacePosition);
}
protected override void ApplyHitAnimations()

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.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Skinning;
@ -12,8 +10,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
internal partial class SkinnableLighting : SkinnableSprite
{
private DrawableHitObject targetObject;
private JudgementResult targetResult;
private DrawableOsuJudgement? targetJudgement;
private JudgementResult? targetResult;
public SkinnableLighting()
: base("lighting")
@ -29,11 +27,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// <summary>
/// Updates the lighting colour from a given hitobject and result.
/// </summary>
/// <param name="targetObject">The <see cref="DrawableHitObject"/> that's been judged.</param>
/// <param name="targetResult">The <see cref="JudgementResult"/> that <paramref name="targetObject"/> was judged with.</param>
public void SetColourFrom(DrawableHitObject targetObject, JudgementResult targetResult)
/// <param name="targetJudgement">The <see cref="DrawableHitObject"/> that's been judged.</param>
/// <param name="targetResult">The <see cref="JudgementResult"/> that <paramref name="targetJudgement"/> was judged with.</param>
public void SetColourFrom(DrawableOsuJudgement targetJudgement, JudgementResult? targetResult)
{
this.targetObject = targetObject;
this.targetJudgement = targetJudgement;
this.targetResult = targetResult;
updateColour();
@ -41,10 +39,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private void updateColour()
{
if (targetObject == null || targetResult == null)
if (targetJudgement == null || targetResult == null)
Colour = Color4.White;
else
Colour = targetResult.IsHit ? targetObject.AccentColour.Value : Color4.Transparent;
Colour = targetResult.IsHit ? targetJudgement.AccentColour : Color4.Transparent;
}
}
}

View File

@ -14,7 +14,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Objects
{
public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPosition
public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPosition, IHasTimePreempt
{
/// <summary>
/// The radius of hit objects (ie. the radius of a <see cref="HitCircle"/>).
@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Objects
/// </summary>
public const double PREEMPT_MAX = 1800;
public double TimePreempt = 600;
public double TimePreempt { get; set; } = 600;
public double TimeFadeIn = 400;
private HitObjectProperty<Vector2> position;

View File

@ -9,6 +9,7 @@ using System.Collections.Generic;
using osu.Game.Rulesets.Objects;
using System.Linq;
using System.Threading;
using JetBrains.Annotations;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Caching;
@ -162,6 +163,10 @@ namespace osu.Game.Rulesets.Osu.Objects
[JsonIgnore]
public SliderTailCircle TailCircle { get; protected set; }
[JsonIgnore]
[CanBeNull]
public SliderRepeat LastRepeat { get; protected set; }
public Slider()
{
SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples();
@ -225,7 +230,7 @@ namespace osu.Game.Rulesets.Osu.Objects
break;
case SliderEventType.Repeat:
AddNested(new SliderRepeat(this)
AddNested(LastRepeat = new SliderRepeat(this)
{
RepeatIndex = e.SpanIndex,
StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration,
@ -248,6 +253,9 @@ namespace osu.Game.Rulesets.Osu.Objects
if (TailCircle != null)
TailCircle.Position = EndPosition;
if (LastRepeat != null)
LastRepeat.Position = RepeatCount % 2 == 0 ? Position : Position + Path.PositionAt(1);
}
protected void UpdateNestedSamples()

View File

@ -359,5 +359,7 @@ namespace osu.Game.Rulesets.Osu
return adjustedDifficulty;
}
public override bool EditorShowScrollSpeed => false;
}
}

View File

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

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
case SkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
// This should eventually be moved to a skin setting, when supported.

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
{
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
case SkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
switch (result)

View File

@ -2,6 +2,7 @@
// 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.Game.Rulesets.Osu.Objects;
@ -41,139 +42,187 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
if (lookup is OsuSkinComponentLookup osuComponent)
switch (lookup)
{
switch (osuComponent.Component)
{
case OsuSkinComponents.FollowPoint:
return this.GetAnimation("followpoint", true, true, true, startAtCurrentTime: false, maxSize: new Vector2(OsuHitObject.OBJECT_RADIUS * 2, OsuHitObject.OBJECT_RADIUS));
case OsuSkinComponents.SliderScorePoint:
return this.GetAnimation("sliderscorepoint", false, false, maxSize: OsuHitObject.OBJECT_DIMENSIONS);
case OsuSkinComponents.SliderFollowCircle:
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
if (followCircleContent != null)
return new LegacyFollowCircle(followCircleContent);
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 OsuSkinComponents.SliderBall:
if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null)
return new LegacySliderBall(this);
// Our own ruleset components default.
switch (containerLookup.Lookup)
{
case GlobalSkinnableContainers.MainHUDComponents:
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;
}
case OsuSkinComponents.SliderBody:
if (hasHitCircle.Value)
return new LegacySliderBody();
var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault();
return null;
if (combo != null)
{
combo.Anchor = Anchor.BottomLeft;
combo.Origin = Anchor.BottomLeft;
combo.Scale = new Vector2(1.28f);
}
})
{
Children = new Drawable[]
{
new LegacyDefaultComboCounter(),
new LegacyKeyCounterDisplay(),
}
};
}
case OsuSkinComponents.SliderTailHitCircle:
if (hasHitCircle.Value)
return new LegacyMainCirclePiece("sliderendcircle", false);
return null;
return null;
case OsuSkinComponentLookup osuComponent:
switch (osuComponent.Component)
{
case OsuSkinComponents.FollowPoint:
return this.GetAnimation("followpoint", true, true, true, startAtCurrentTime: false,
maxSize: new Vector2(OsuHitObject.OBJECT_RADIUS * 2, OsuHitObject.OBJECT_RADIUS));
case OsuSkinComponents.SliderHeadHitCircle:
if (hasHitCircle.Value)
return new LegacySliderHeadHitCircle();
case OsuSkinComponents.SliderScorePoint:
return this.GetAnimation("sliderscorepoint", false, false, maxSize: OsuHitObject.OBJECT_DIMENSIONS);
return null;
case OsuSkinComponents.SliderFollowCircle:
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
if (followCircleContent != null)
return new LegacyFollowCircle(followCircleContent);
case OsuSkinComponents.ReverseArrow:
if (hasHitCircle.Value)
return new LegacyReverseArrow();
return null;
case OsuSkinComponents.HitCircle:
if (hasHitCircle.Value)
return new LegacyMainCirclePiece();
return null;
case OsuSkinComponents.Cursor:
if (GetTexture("cursor") != null)
return new LegacyCursor(this);
return null;
case OsuSkinComponents.CursorTrail:
if (GetTexture("cursortrail") != null)
return new LegacyCursorTrail(this);
return null;
case OsuSkinComponents.CursorRipple:
if (GetTexture("cursor-ripple") != null)
{
var ripple = this.GetAnimation("cursor-ripple", false, false);
// In stable this element was scaled down to 50% and opacity 20%, but this makes the elements WAY too big and inflexible.
// If anyone complains about these not being applied, this can be uncommented.
//
// But if no one complains I'd rather fix this in lazer. Wiki documentation doesn't mention size,
// so we might be okay.
//
// if (ripple != null)
// {
// ripple.Scale = new Vector2(0.5f);
// ripple.Alpha = 0.2f;
// }
return ripple;
}
return null;
case OsuSkinComponents.CursorParticles:
if (GetTexture("star2") != null)
return new LegacyCursorParticles();
return null;
case OsuSkinComponents.CursorSmoke:
if (GetTexture("cursor-smoke") != null)
return new LegacySmokeSegment();
return null;
case OsuSkinComponents.HitCircleText:
if (!this.HasFont(LegacyFont.HitCircle))
return null;
const float hitcircle_text_scale = 0.8f;
return new LegacySpriteText(LegacyFont.HitCircle)
{
// stable applies a blanket 0.8x scale to hitcircle fonts
Scale = new Vector2(hitcircle_text_scale),
MaxSizePerGlyph = OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale,
};
case OsuSkinComponents.SliderBall:
if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null)
return new LegacySliderBall(this);
case OsuSkinComponents.SpinnerBody:
bool hasBackground = GetTexture("spinner-background") != null;
return null;
if (GetTexture("spinner-top") != null && !hasBackground)
return new LegacyNewStyleSpinner();
else if (hasBackground)
return new LegacyOldStyleSpinner();
case OsuSkinComponents.SliderBody:
if (hasHitCircle.Value)
return new LegacySliderBody();
return null;
return null;
case OsuSkinComponents.ApproachCircle:
if (GetTexture(@"approachcircle") != null)
return new LegacyApproachCircle();
case OsuSkinComponents.SliderTailHitCircle:
if (hasHitCircle.Value)
return new LegacyMainCirclePiece("sliderendcircle", false);
return null;
return null;
default:
throw new UnsupportedSkinComponentException(lookup);
}
case OsuSkinComponents.SliderHeadHitCircle:
if (hasHitCircle.Value)
return new LegacySliderHeadHitCircle();
return null;
case OsuSkinComponents.ReverseArrow:
if (hasHitCircle.Value)
return new LegacyReverseArrow();
return null;
case OsuSkinComponents.HitCircle:
if (hasHitCircle.Value)
return new LegacyMainCirclePiece();
return null;
case OsuSkinComponents.Cursor:
if (GetTexture("cursor") != null)
return new LegacyCursor(this);
return null;
case OsuSkinComponents.CursorTrail:
if (GetTexture("cursortrail") != null)
return new LegacyCursorTrail(this);
return null;
case OsuSkinComponents.CursorRipple:
if (GetTexture("cursor-ripple") != null)
{
var ripple = this.GetAnimation("cursor-ripple", false, false);
// In stable this element was scaled down to 50% and opacity 20%, but this makes the elements WAY too big and inflexible.
// If anyone complains about these not being applied, this can be uncommented.
//
// But if no one complains I'd rather fix this in lazer. Wiki documentation doesn't mention size,
// so we might be okay.
//
// if (ripple != null)
// {
// ripple.Scale = new Vector2(0.5f);
// ripple.Alpha = 0.2f;
// }
return ripple;
}
return null;
case OsuSkinComponents.CursorParticles:
if (GetTexture("star2") != null)
return new LegacyCursorParticles();
return null;
case OsuSkinComponents.CursorSmoke:
if (GetTexture("cursor-smoke") != null)
return new LegacySmokeSegment();
return null;
case OsuSkinComponents.HitCircleText:
if (!this.HasFont(LegacyFont.HitCircle))
return null;
const float hitcircle_text_scale = 0.8f;
return new LegacySpriteText(LegacyFont.HitCircle)
{
// stable applies a blanket 0.8x scale to hitcircle fonts
Scale = new Vector2(hitcircle_text_scale),
MaxSizePerGlyph = OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale,
};
case OsuSkinComponents.SpinnerBody:
bool hasBackground = GetTexture("spinner-background") != null;
if (GetTexture("spinner-top") != null && !hasBackground)
return new LegacyNewStyleSpinner();
else if (hasBackground)
return new LegacyOldStyleSpinner();
return null;
case OsuSkinComponents.ApproachCircle:
if (GetTexture(@"approachcircle") != null)
return new LegacyApproachCircle();
return null;
default:
throw new UnsupportedSkinComponentException(lookup);
}
default:
return base.GetDrawableComponent(lookup);
}
return base.GetDrawableComponent(lookup);
}
public override IBindable<TValue>? GetConfig<TLookup, TValue>(TLookup lookup)

View File

@ -16,7 +16,6 @@ using osu.Framework.Graphics.Shaders.Types;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
using osu.Framework.Timing;
using osuTK;
using osuTK.Graphics;
@ -39,6 +38,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
private double timeOffset;
private float time;
/// <summary>
/// The scale used on creation of a new trail part.
/// </summary>
public Vector2 NewPartScale = Vector2.One;
private Anchor trailOrigin = Anchor.Centre;
protected Anchor TrailOrigin
@ -63,8 +67,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
// -1 signals that the part is unusable, and should not be drawn
parts[i].InvalidationID = -1;
}
AddLayout(partSizeCache);
}
[BackgroundDependencyLoader]
@ -95,12 +97,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
}
}
private readonly LayoutValue<Vector2> partSizeCache = new LayoutValue<Vector2>(Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit | Invalidation.Presence);
private Vector2 partSize => partSizeCache.IsValid
? partSizeCache.Value
: (partSizeCache.Value = new Vector2(Texture.DisplayWidth, Texture.DisplayHeight) * DrawInfo.Matrix.ExtractScale().Xy);
/// <summary>
/// The amount of time to fade the cursor trail pieces.
/// </summary>
@ -156,6 +152,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
protected void AddTrail(Vector2 position)
{
position = ToLocalSpace(position);
if (InterpolateMovements)
{
if (!lastPosition.HasValue)
@ -174,7 +172,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
float distance = diff.Length;
Vector2 direction = diff / distance;
float interval = partSize.X / 2.5f * IntervalMultiplier;
float interval = Texture.DisplayWidth / 2.5f * IntervalMultiplier;
float stopAt = distance - (AvoidDrawingNearCursor ? interval : 0);
for (float d = interval; d < stopAt; d += interval)
@ -191,10 +189,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
}
}
private void addPart(Vector2 screenSpacePosition)
private void addPart(Vector2 localSpacePosition)
{
parts[currentIndex].Position = screenSpacePosition;
parts[currentIndex].Position = localSpacePosition;
parts[currentIndex].Time = time + 1;
parts[currentIndex].Scale = NewPartScale;
++parts[currentIndex].InvalidationID;
currentIndex = (currentIndex + 1) % max_sprites;
@ -206,6 +205,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
{
public Vector2 Position;
public float Time;
public Vector2 Scale;
public long InvalidationID;
}
@ -220,7 +220,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
private float fadeExponent;
private readonly TrailPart[] parts = new TrailPart[max_sprites];
private Vector2 size;
private Vector2 originPosition;
private IVertexBatch<TexturedTrailVertex> vertexBatch;
@ -236,7 +235,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
shader = Source.shader;
texture = Source.texture;
size = Source.partSize;
time = Source.time;
fadeExponent = Source.FadeExponent;
@ -277,6 +275,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
RectangleF textureRect = texture.GetTextureRect();
renderer.PushLocalMatrix(DrawInfo.Matrix);
foreach (var part in parts)
{
if (part.InvalidationID == -1)
@ -287,7 +287,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex
{
Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y + size.Y * (1 - originPosition.Y)),
Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
TexturePosition = textureRect.BottomLeft,
TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.BottomLeft.Linear,
@ -296,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex
{
Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y + size.Y * (1 - originPosition.Y)),
Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
TexturePosition = textureRect.BottomRight,
TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.BottomRight.Linear,
@ -305,7 +305,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex
{
Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y - size.Y * originPosition.Y),
Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
TexturePosition = textureRect.TopRight,
TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.TopRight.Linear,
@ -314,7 +314,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex
{
Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y - size.Y * originPosition.Y),
Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
TexturePosition = textureRect.TopLeft,
TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.TopLeft.Linear,
@ -322,6 +322,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
});
}
renderer.PopLocalMatrix();
vertexBatch.Draw();
shader.Unbind();
}

View File

@ -31,6 +31,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
private SkinnableCursor skinnableCursor => (SkinnableCursor)cursorSprite.Drawable;
/// <summary>
/// The current expanded scale of the cursor.
/// </summary>
public Vector2 CurrentExpandedScale => skinnableCursor.ExpandTarget?.Scale ?? Vector2.One;
public IBindable<float> CursorScale => cursorScale;
private readonly Bindable<float> cursorScale = new BindableFloat(1);

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