1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-15 16:27:43 +08:00

Merge branch 'master' into additions

This commit is contained in:
Dean Herbert 2024-10-01 16:36:59 +09:00
commit 1b4215576d
No known key found for this signature in database
400 changed files with 9615 additions and 3253 deletions

View File

@ -135,5 +135,8 @@ jobs:
- name: Install .NET Workloads - name: Install .NET Workloads
run: dotnet workload install maui-ios run: dotnet workload install maui-ios
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer
- name: Build - name: Build
run: dotnet build -c Debug osu.iOS run: dotnet build -c Debug osu.iOS

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.809.2" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2024.927.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- 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; host.Window.CursorState |= CursorState.Hidden;
} }
protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier();
protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo(); protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo();

View File

@ -2,10 +2,10 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Threading.Tasks;
using Microsoft.Win32; using Microsoft.Win32;
using osu.Desktop.Performance; using osu.Desktop.Performance;
using osu.Desktop.Security; using osu.Desktop.Security;
@ -95,44 +95,22 @@ namespace osu.Desktop
return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); 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() protected override UpdateManager CreateUpdateManager()
{ {
string? packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"); if (IsPackageManaged)
if (!string.IsNullOrEmpty(packageManaged))
return new NoActionUpdateManager(); return new NoActionUpdateManager();
switch (RuntimeInfo.OS) return new VelopackUpdateManager();
{
case RuntimeInfo.Platform.Windows:
Debug.Assert(OperatingSystem.IsWindows());
return new SquirrelUpdateManager();
default:
return new SimpleUpdateManager();
}
} }
public override bool RestartAppWhenExited() public override bool RestartAppWhenExited()
{ {
switch (RuntimeInfo.OS) Task.Run(() => Velopack.UpdateExe.Start()).FireAndForget();
{
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 true;
} }
return base.RestartAppWhenExited();
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();

View File

@ -14,7 +14,7 @@ using osu.Game;
using osu.Game.IPC; using osu.Game.IPC;
using osu.Game.Tournament; using osu.Game.Tournament;
using SDL; using SDL;
using Squirrel; using Velopack;
namespace osu.Desktop namespace osu.Desktop
{ {
@ -31,19 +31,11 @@ namespace osu.Desktop
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
/* // IMPORTANT DON'T IGNORE: For general sanity, velopack's setup needs to run before anything else.
* WARNING: DO NOT PLACE **ANY** CODE ABOVE THE FOLLOWING BLOCK! // 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.
* Logic handling Squirrel MUST run before EVERYTHING if you do not want to break it. setupVelopack();
* 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")
*/
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
var windowsVersion = Environment.OSVersion.Version; var windowsVersion = Environment.OSVersion.Version;
@ -66,8 +58,6 @@ namespace osu.Desktop
return; return;
} }
} }
setupSquirrel();
} }
// NVIDIA profiles are based on the executable name of a process. // NVIDIA profiles are based on the executable name of a process.
@ -177,32 +167,28 @@ namespace osu.Desktop
return false; return false;
} }
private static void setupVelopack()
{
if (OsuGameDesktop.IsPackageManaged)
{
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")] [SupportedOSPlatform("windows")]
private static void setupSquirrel() private static void configureWindows(VelopackApp app)
{ {
SquirrelAwareApp.HandleEvents(onInitialInstall: (_, tools) => app.WithFirstRun(_ => WindowsAssociationManager.InstallAssociations());
{ app.WithAfterUpdateFastCallback(_ => WindowsAssociationManager.UpdateAssociations());
tools.CreateShortcutForThisExe(); app.WithBeforeUninstallFastCallback(_ => WindowsAssociationManager.UninstallAssociations());
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();
});
} }
} }
} }

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,140 @@
// 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 = () =>
{
Task.Run(restartToApplyUpdate);
return true;
}
});
return true;
}
pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false);
// No update is available. We'll check again later.
if (pendingUpdate == null)
{
scheduleRecheck = true;
return false;
}
// An update is found, let's notify the user and start downloading it.
if (notification == null)
{
notification = new UpdateProgressNotification
{
CompletionClickAction = () =>
{
Task.Run(restartToApplyUpdate);
return true;
},
};
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 async Task restartToApplyUpdate()
{
await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
}
}
}

View File

@ -13,5 +13,7 @@ namespace osu.Desktop.Windows
private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!; 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 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 = private static readonly FileAssociation[] file_associations =
{ {
new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Beatmap),
new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Beatmap),
new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Lazer), new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Beatmap),
new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Lazer), new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Beatmap),
}; };
private static readonly UriAssociation[] uri_associations = 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> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>A free-to-win rhythm game. Rhythm is just a *click* away!</Description> <Description>A free-to-win rhythm game. Rhythm is just a *click* away!</Description>
<AssemblyName>osu!</AssemblyName> <AssemblyName>osu!</AssemblyName>
<AssemblyTitle>osu!(lazer)</AssemblyTitle>
<Title>osu!</Title> <Title>osu!</Title>
<Product>osu!(lazer)</Product> <Product>osu!(lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon> <ApplicationIcon>lazer.ico</ApplicationIcon>
@ -23,9 +24,9 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Clowd.Squirrel" Version="2.11.1" />
<PackageReference Include="System.IO.Packaging" Version="8.0.0" /> <PackageReference Include="System.IO.Packaging" Version="8.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" /> <PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.630-g9c52e40" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Resources"> <ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" /> <EmbeddedResource Include="lazer.ico" />

View File

@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using BenchmarkDotNet.Attributes;
using osu.Framework.Utils;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Benchmarks
{
public class BenchmarkGeometryUtils : BenchmarkTest
{
[Params(100, 1000, 2000, 4000, 8000, 10000)]
public int N;
private Vector2[] points = null!;
public override void SetUp()
{
points = new Vector2[N];
for (int i = 0; i < points.Length; ++i)
points[i] = new Vector2(RNG.Next(512), RNG.Next(384));
}
[Benchmark]
public void MinimumEnclosingCircle() => GeometryUtils.MinimumEnclosingCircle(points);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Catch.Tests
if (withModifiedSkin) if (withModifiedSkin)
{ {
AddStep("change component scale", () => Player.ChildrenOfType<LegacyScoreCounter>().First().Scale = new Vector2(2f)); 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()); AddStep("exit player", () => Player.Exit());
CreateTest(); CreateTest();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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. // The implementation below is probably correct but should be checked if/when exposed via controls.
float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); 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; return actualDistance / expectedDistance;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,23 +30,19 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case GlobalSkinnableContainerLookup containerLookup:
// Only handle per ruleset defaults here. // Only handle per ruleset defaults here.
if (containerLookup.Ruleset == null) if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup); return base.GetDrawableComponent(lookup);
// Skin has configuration.
if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer d)
return d;
// we don't have enough assets to display these components (this is especially the case on a "beatmap" skin). // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin).
if (!IsProvidingLegacyResources) if (!IsProvidingLegacyResources)
return null; return null;
// Our own ruleset components default. // Our own ruleset components default.
switch (containerLookup.Target) switch (containerLookup.Lookup)
{ {
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: case GlobalSkinnableContainers.MainHUDComponents:
// todo: remove CatchSkinComponents.CatchComboCounter and refactor LegacyCatchComboCounter to be added here instead. // todo: remove CatchSkinComponents.CatchComboCounter and refactor LegacyCatchComboCounter to be added here instead.
return new DefaultSkinComponentsContainer(container => return new DefaultSkinComponentsContainer(container =>
{ {
@ -56,10 +52,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
// set the anchor to top right so that it won't squash to the return button to the top // set the anchor to top right so that it won't squash to the return button to the top
keyCounter.Anchor = Anchor.CentreRight; keyCounter.Anchor = Anchor.CentreRight;
keyCounter.Origin = Anchor.CentreRight; keyCounter.Origin = Anchor.TopRight;
keyCounter.X = 0; keyCounter.Position = new Vector2(0, -40) * 1.6f;
// 340px is the default height inherit from stable
keyCounter.Y = container.ToLocalSpace(new Vector2(0, container.ScreenSpaceDrawQuad.Centre.Y - 340f)).Y;
} }
}) })
{ {

View File

@ -85,9 +85,25 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
protected void SetTexture(Texture? texture, Texture? overlayTexture) protected void SetTexture(Texture? texture, Texture? overlayTexture)
{ {
// 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; colouredSprite.Texture = texture;
}
if (overlaySprite.Texture != overlayTexture)
{
overlaySprite.Size = Vector2.Zero;
overlaySprite.Texture = overlayTexture; overlaySprite.Texture = overlayTexture;
}
if (hyperSprite.Texture != texture)
{
hyperSprite.Size = Vector2.Zero;
hyperSprite.Texture = texture; hyperSprite.Texture = texture;
} }
} }
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Skinning.Argon; using osu.Game.Rulesets.Mania.Skinning.Argon;
using osu.Game.Rulesets.Mania.Skinning.Legacy; using osu.Game.Rulesets.Mania.Skinning.Legacy;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Tests.Skinning namespace osu.Game.Rulesets.Mania.Tests.Skinning
@ -17,22 +21,75 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
[Cached] [Cached]
private ScoreProcessor scoreProcessor = new ScoreProcessor(new ManiaRuleset()); private ScoreProcessor scoreProcessor = new ScoreProcessor(new ManiaRuleset());
[SetUpSteps] [Test]
public void SetUpSteps() public void TestDisplay()
{ {
AddStep("setup", () => SetContents(s => setup(Anchor.Centre);
{
if (s is ArgonSkin)
return new ArgonManiaComboCounter();
if (s is LegacySkin)
return new LegacyManiaComboCounter();
return new LegacyManiaComboCounter();
}));
AddRepeatStep("perform hit", () => scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Great }), 20); 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 })); 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

@ -6,7 +6,6 @@ using System;
using System.Linq; using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
@ -271,7 +270,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
Duration = endTimeData.Duration, Duration = endTimeData.Duration,
Column = column, Column = column,
Samples = HitObject.Samples, Samples = HitObject.Samples,
NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? defaultNodeSamples NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? HoldNote.CreateDefaultNodeSamples(HitObject)
}); });
} }
else if (HitObject is IHasXPosition) else if (HitObject is IHasXPosition)
@ -286,16 +285,6 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
return pattern; 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 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 bool isForCurrentRuleset;
private readonly double originalOverallDifficulty; private readonly double originalOverallDifficulty;
@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{ {
StarRating = skills[0].DifficultyValue() * star_scaling_factor, StarRating = skills[0].DifficultyValue() * difficulty_multiplier,
Mods = mods, Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window. // 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. // 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); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
scoreAccuracy = calculateCustomAccuracy(); scoreAccuracy = calculateCustomAccuracy();
// Arbitrary initial value for scaling pp in order to standardize distributions across game modes. double multiplier = 1.0;
// The specific number has no intrinsic meaning and can be adjusted as needed.
double multiplier = 8.0;
if (score.Mods.Any(m => m is ModNoFail)) if (score.Mods.Any(m => m is ModNoFail))
multiplier *= 0.75; multiplier *= 0.75;
@ -59,7 +57,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
private double computeDifficultyValue(ManiaDifficultyAttributes attributes) private double computeDifficultyValue(ManiaDifficultyAttributes attributes)
{ {
double difficultyValue = Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve 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 * 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 * (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
@ -91,6 +92,10 @@ namespace osu.Game.Rulesets.Mania.Objects
{ {
base.CreateNestedHitObjects(cancellationToken); 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 AddNested(Head = new HeadNote
{ {
StartTime = StartTime, StartTime = StartTime,
@ -102,7 +107,7 @@ namespace osu.Game.Rulesets.Mania.Objects
{ {
StartTime = EndTime, StartTime = EndTime,
Column = Column, Column = Column,
Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), Samples = GetNodeSamples(NodeSamples.Count - 1),
}); });
AddNested(Body = new HoldNoteBody AddNested(Body = new HoldNoteBody
@ -116,7 +121,20 @@ namespace osu.Game.Rulesets.Mania.Objects
protected override HitWindows CreateHitWindows() => HitWindows.Empty; protected override HitWindows CreateHitWindows() => HitWindows.Empty;
public IList<HitSampleInfo> GetNodeSamples(int nodeIndex) => public IList<HitSampleInfo> GetNodeSamples(int nodeIndex) => nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples;
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

@ -38,11 +38,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
private void updateAnchor() private void updateAnchor()
{ {
// if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction // if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction
if (!Anchor.HasFlag(Anchor.y1)) if (Anchor.HasFlag(Anchor.y1))
{ return;
Anchor &= ~(Anchor.y0 | Anchor.y2); Anchor &= ~(Anchor.y0 | Anchor.y2);
Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0; Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0;
}
// change the sign of the Y coordinate in line with the scrolling direction. // 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. // 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.

View File

@ -28,18 +28,14 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
{ {
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case GlobalSkinnableContainerLookup containerLookup:
// Only handle per ruleset defaults here. // Only handle per ruleset defaults here.
if (containerLookup.Ruleset == null) if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup); return base.GetDrawableComponent(lookup);
// Skin has configuration. switch (containerLookup.Lookup)
if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer d)
return d;
switch (containerLookup.Target)
{ {
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: case GlobalSkinnableContainers.MainHUDComponents:
return new DefaultSkinComponentsContainer(container => return new DefaultSkinComponentsContainer(container =>
{ {
var combo = container.ChildrenOfType<ArgonManiaComboCounter>().FirstOrDefault(); var combo = container.ChildrenOfType<ArgonManiaComboCounter>().FirstOrDefault();
@ -59,7 +55,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon
return null; return null;
case GameplaySkinComponentLookup<HitResult> resultComponent: case SkinComponentLookup<HitResult> resultComponent:
// This should eventually be moved to a skin setting, when supported. // This should eventually be moved to a skin setting, when supported.
if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great)
return Drawable.Empty(); return Drawable.Empty();

View File

@ -1,9 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Globalization;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
@ -11,17 +15,76 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Skinning.Legacy namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{ {
public partial class LegacyManiaComboCounter : LegacyComboCounter public partial class LegacyManiaComboCounter : CompositeDrawable, ISerialisableDrawable
{ {
[BackgroundDependencyLoader] public bool UsesFixedAnchor { get; set; }
private void load(ISkinSource skin)
{
DisplayedCountText.Anchor = Anchor.Centre;
DisplayedCountText.Origin = Anchor.Centre;
PopOutCountText.Anchor = Anchor.Centre; public Bindable<int> Current { get; } = new BindableInt { MinValue = 0 };
PopOutCountText.Origin = Anchor.Centre;
PopOutCountText.Colour = skin.GetManiaSkinConfig<Color4>(LegacyManiaSkinConfigurationLookups.ComboBreakColour)?.Value ?? Color4.Red; /// <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] [Resolved]
@ -33,6 +96,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{ {
base.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 = scrollingInfo.Direction.GetBoundCopy();
direction.BindValueChanged(_ => updateAnchor()); direction.BindValueChanged(_ => updateAnchor());
@ -44,48 +113,82 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private void updateAnchor() private void updateAnchor()
{ {
// if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction // if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction
if (!Anchor.HasFlag(Anchor.y1)) if (Anchor.HasFlag(Anchor.y1))
{ return;
Anchor &= ~(Anchor.y0 | Anchor.y2); Anchor &= ~(Anchor.y0 | Anchor.y2);
Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0; 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);
} }
// since we flip the vertical anchor when changing scroll direction, private void updateCount(bool rolling)
// we can use the sign of the Y value as an indicator to make the combo counter displayed correctly.
if ((Y < 0 && direction.Value == ScrollingDirection.Down) || (Y > 0 && direction.Value == ScrollingDirection.Up))
Y = -Y;
}
protected override void OnCountIncrement()
{ {
base.OnCountIncrement(); int prev = previousValue;
previousValue = Current.Value;
PopOutCountText.Hide(); if (!IsLoaded)
DisplayedCountText.ScaleTo(new Vector2(1f, 1.4f)) 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) .ScaleTo(new Vector2(1f), 300, Easing.Out)
.FadeIn(120); .FadeIn(120);
} }
protected override void OnCountChange() private void onCountChange()
{ {
base.OnCountChange(); popOutCountText.Hide();
PopOutCountText.Hide(); if (Current.Value == 0)
DisplayedCountText.ScaleTo(1f); displayedCountText.FadeOut();
DisplayedCount = Current.Value;
displayedCountText.ScaleTo(1f);
} }
protected override void OnCountRolling() private void onCountRolling()
{ {
if (DisplayedCount > 0) if (DisplayedCount > 0)
{ {
PopOutCountText.Text = FormatCount(DisplayedCount); popOutCountText.Text = DisplayedCount.ToString(CultureInfo.InvariantCulture);
PopOutCountText.FadeTo(0.8f).FadeOut(200) popOutCountText.FadeTo(0.8f).FadeOut(200)
.ScaleTo(1f).ScaleTo(4f, 200); .ScaleTo(1f).ScaleTo(4f, 200);
DisplayedCountText.FadeTo(0.5f, 300); displayedCountText.FadeTo(0.5f, 300);
} }
base.OnCountRolling(); // 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

@ -80,22 +80,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{ {
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case GlobalSkinnableContainerLookup containerLookup:
// Modifications for global components. // Modifications for global components.
if (containerLookup.Ruleset == null) if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup); return base.GetDrawableComponent(lookup);
// Skin has configuration.
if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer d)
return d;
// we don't have enough assets to display these components (this is especially the case on a "beatmap" skin). // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin).
if (!IsProvidingLegacyResources) if (!IsProvidingLegacyResources)
return null; return null;
switch (containerLookup.Target) switch (containerLookup.Lookup)
{ {
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: case GlobalSkinnableContainers.MainHUDComponents:
return new DefaultSkinComponentsContainer(container => return new DefaultSkinComponentsContainer(container =>
{ {
var combo = container.ChildrenOfType<LegacyManiaComboCounter>().FirstOrDefault(); var combo = container.ChildrenOfType<LegacyManiaComboCounter>().FirstOrDefault();
@ -114,7 +110,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
return null; return null;
case GameplaySkinComponentLookup<HitResult> resultComponent: case SkinComponentLookup<HitResult> resultComponent:
return getResult(resultComponent.Component); return getResult(resultComponent.Component);
case ManiaSkinComponentLookup maniaComponent: case ManiaSkinComponentLookup maniaComponent:

View File

@ -99,12 +99,6 @@ namespace osu.Game.Rulesets.Mania.UI
return false; return false;
} }
protected override bool OnMouseDown(MouseDownEvent e)
{
Show();
return true;
}
protected override bool OnTouchDown(TouchDownEvent e) protected override bool OnTouchDown(TouchDownEvent e)
{ {
Show(); Show();
@ -172,17 +166,6 @@ namespace osu.Game.Rulesets.Mania.UI
updateButton(false); 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) private void updateButton(bool press)
{ {
if (press == isPressed) if (press == isPressed)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,47 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderChangeStates : TestSceneOsuEditor
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[TestCase(SplineType.Catmull)]
[TestCase(SplineType.BSpline)]
[TestCase(SplineType.Linear)]
[TestCase(SplineType.PerfectCurve)]
public void TestSliderRetainsCurveTypes(SplineType splineType)
{
Slider? slider = null;
PathType pathType = new PathType(splineType);
AddStep("add slider", () => EditorBeatmap.Add(slider = new Slider
{
StartTime = 500,
Path = new SliderPath(new[]
{
new PathControlPoint(Vector2.Zero, pathType),
new PathControlPoint(new Vector2(200, 0), pathType),
})
}));
AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType));
AddStep("remove object", () => EditorBeatmap.Remove(slider));
AddAssert("slider removed", () => EditorBeatmap.HitObjects.Count == 0);
addUndoSteps();
AddAssert("slider not removed", () => EditorBeatmap.HitObjects.Count == 1);
AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType));
}
private void addUndoSteps() => AddStep("undo", () => Editor.Undo());
}
}

View File

@ -514,6 +514,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private Slider? getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null; private Slider? getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject); protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint(); protected override HitObjectPlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
} }
} }

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
@ -22,9 +20,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene
{ {
private Slider slider; private Slider slider = null!;
private DrawableSlider drawableObject; private DrawableSlider drawableObject = null!;
private TestSliderBlueprint blueprint; private TestSliderBlueprint blueprint = null!;
[SetUp] [SetUp]
public void Setup() => Schedule(() => public void Setup() => Schedule(() =>
@ -163,6 +161,44 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
checkControlPointSelected(1, false); 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() private void moveHitObject()
{ {
AddStep("move hitobject", () => AddStep("move hitobject", () =>
@ -180,6 +216,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("tail positioned correctly", AddAssert("tail positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre)); () => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre));
AddAssert("end drag marker positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.EndDragMarker!.ToScreenSpace(blueprint.TailOverlay.EndDragMarker.OriginPosition), drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre, 2));
} }
private void moveMouseToControlPoint(int index) private void moveMouseToControlPoint(int index)
@ -192,14 +231,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
} }
private void checkControlPointSelected(int index, bool selected) private void checkControlPointSelected(int index, bool selected)
=> AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected); => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser!.Pieces[index].IsSelected.Value == selected);
private partial class TestSliderBlueprint : SliderSelectionBlueprint private partial class TestSliderBlueprint : SliderSelectionBlueprint
{ {
public new SliderBodyPiece BodyPiece => base.BodyPiece; public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay; public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay; public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
public new PathControlPointVisualiser<Slider> ControlPointVisualiser => base.ControlPointVisualiser; public new PathControlPointVisualiser<Slider>? ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(Slider slider) public TestSliderBlueprint(Slider slider)
: base(slider) : base(slider)

View File

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

View File

@ -88,6 +88,21 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("trail is disjoint", () => this.ChildrenOfType<LegacyCursorTrail>().Single().DisjointTrail, () => Is.True); 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", () => private void createTest(Func<Drawable> createContent) => AddStep("create trail", () =>
{ {
Clear(); 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

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

View File

@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{ {
public static class SpeedEvaluator 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 min_speed_bonus = 75; // ~200BPM
private const double speed_balancing_factor = 40; 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. // 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); 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; double speedBonus = 1.0;
// Add additional scaling bonus for streams/bursts higher than 200bpm
if (strainTime < min_speed_bonus) if (strainTime < min_speed_bonus)
speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
double travelDistance = osuPrevObj?.TravelDistance ?? 0; 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

@ -81,7 +81,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate; double drainRate = beatmap.Difficulty.DrainRate;
int maxCombo = beatmap.GetMaxCombo();
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
@ -104,7 +103,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6, OverallDifficulty = (80 - hitWindowGreat) / 6,
DrainRate = drainRate, DrainRate = drainRate,
MaxCombo = maxCombo, MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount, HitCircleCount = hitCirclesCount,
SliderCount = sliderCount, SliderCount = sliderCount,
SpinnerCount = spinnerCount, SpinnerCount = spinnerCount,

View File

@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain; private double currentStrain;
private double skillMultiplier => 23.55; private double skillMultiplier => 24.963;
private double strainDecayBase => 0.15; private double strainDecayBase => 0.15;
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); 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); hasHiddenMod = mods.Any(m => m is OsuModHidden);
} }
private double skillMultiplier => 0.052; private double skillMultiplier => 0.05512;
private double strainDecayBase => 0.15; private double strainDecayBase => 0.15;
private double currentStrain; private double currentStrain;
@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return currentStrain; 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); 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 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> /// <summary>
/// The number of sections with the highest strains, which the peak strain reductions will apply to. /// 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. /// 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> /// </summary>
protected virtual double ReducedStrainBaseline => 0.75; 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) protected OsuStrainSkill(Mod[] mods)
: base(mods) : base(mods)
{ {
@ -65,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
weight *= DecayWeight; 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; 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> /// </summary>
public class Speed : OsuStrainSkill public class Speed : OsuStrainSkill
{ {
private double skillMultiplier => 1375; private double skillMultiplier => 1.430;
private double strainDecayBase => 0.3; private double strainDecayBase => 0.3;
private double currentStrain; private double currentStrain;
private double currentRhythm; private double currentRhythm;
protected override int ReducedSectionCount => 5; protected override int ReducedSectionCount => 5;
protected override double DifficultyMultiplier => 1.04;
private readonly List<double> objectStrains = new List<double>(); private readonly List<double> objectStrains = new List<double>();

View File

@ -9,7 +9,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
{ {
public partial class HitCirclePlacementBlueprint : PlacementBlueprint public partial class HitCirclePlacementBlueprint : HitObjectPlacementBlueprint
{ {
public new HitCircle HitObject => (HitCircle)base.HitObject; public new HitCircle HitObject => (HitCircle)base.HitObject;

View File

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

View File

@ -353,6 +353,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{ {
changeHandler?.BeginChange(); changeHandler?.BeginChange();
double originalDistance = hitObject.Path.Distance;
foreach (var p in Pieces.Where(p => p.IsSelected.Value)) foreach (var p in Pieces.Where(p => p.IsSelected.Value))
{ {
var pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint); var pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
@ -375,6 +377,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
EnsureValidPathTypes(); EnsureValidPathTypes();
if (hitObject.Path.Distance < originalDistance)
hitObject.SnapTo(distanceSnapProvider);
else
hitObject.Path.ExpectedDistance.Value = originalDistance;
changeHandler?.EndChange(); changeHandler?.EndChange();
} }

View File

@ -1,7 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // 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.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
@ -9,11 +12,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{ {
public partial class SliderCircleOverlay : CompositeDrawable 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; protected readonly HitCirclePiece CirclePiece;
private readonly Slider slider; private readonly Slider slider;
private readonly SliderPosition position; private readonly SliderPosition position;
private readonly HitCircleOverlapMarker? marker; private readonly HitCircleOverlapMarker? marker;
private readonly Container? endDragMarkerContainer;
public SliderCircleOverlay(Slider slider, SliderPosition position) public SliderCircleOverlay(Slider slider, SliderPosition position)
{ {
@ -24,26 +44,49 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
AddInternal(marker = new HitCircleOverlapMarker()); AddInternal(marker = new HitCircleOverlapMarker());
AddInternal(CirclePiece = new HitCirclePiece()); 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() protected override void Update()
{ {
base.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); CirclePiece.UpdateFrom(circle);
marker?.UpdateFrom(circle); marker?.UpdateFrom(circle);
if (endDragMarkerContainer != null)
{
endDragMarkerContainer.Position = circle.Position + slider.StackOffset;
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() public override void Hide()
{ {
CirclePiece.Hide(); CirclePiece.Hide();
endDragMarkerContainer?.Hide();
} }
public override void Show() public override void Show()
{ {
CirclePiece.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. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input; using osu.Framework.Input;
@ -25,34 +21,33 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{ {
public partial class SliderPlacementBlueprint : PlacementBlueprint public partial class SliderPlacementBlueprint : HitObjectPlacementBlueprint
{ {
public new Slider HitObject => (Slider)base.HitObject; public new Slider HitObject => (Slider)base.HitObject;
private SliderBodyPiece bodyPiece; private SliderBodyPiece bodyPiece = null!;
private HitCirclePiece headCirclePiece; private HitCirclePiece headCirclePiece = null!;
private HitCirclePiece tailCirclePiece; private HitCirclePiece tailCirclePiece = null!;
private PathControlPointVisualiser<Slider> controlPointVisualiser; private PathControlPointVisualiser<Slider> controlPointVisualiser = null!;
private InputManager inputManager; private InputManager inputManager = null!;
private PathControlPoint? cursor;
private SliderPlacementState state; private SliderPlacementState state;
private PathControlPoint segmentStart; private PathControlPoint segmentStart;
private PathControlPoint cursor;
private int currentSegmentLength; private int currentSegmentLength;
private bool usingCustomSegmentType; private bool usingCustomSegmentType;
[Resolved(CanBeNull = true)] [Resolved]
[CanBeNull] private IPositionSnapProvider? positionSnapProvider { get; set; }
private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)] [Resolved]
[CanBeNull] private IDistanceSnapProvider? distanceSnapProvider { get; set; }
private IDistanceSnapProvider distanceSnapProvider { get; set; }
[Resolved(CanBeNull = true)] [Resolved]
[CanBeNull] private FreehandSliderToolboxGroup? freehandToolboxGroup { get; set; }
private FreehandSliderToolboxGroup freehandToolboxGroup { get; set; }
private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 }; private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 };
@ -84,7 +79,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
inputManager = GetContainingInputManager();
inputManager = GetContainingInputManager()!;
if (freehandToolboxGroup != null) if (freehandToolboxGroup != null)
{ {
@ -108,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
} }
[Resolved] [Resolved]
private EditorBeatmap editorBeatmap { get; set; } private EditorBeatmap editorBeatmap { get; set; } = null!;
public override void UpdateTimeAndPosition(SnapResult result) public override void UpdateTimeAndPosition(SnapResult result)
{ {
@ -151,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
case SliderPlacementState.ControlPoints: case SliderPlacementState.ControlPoints:
if (canPlaceNewControlPoint(out var lastPoint)) if (canPlaceNewControlPoint(out var lastPoint))
placeNewControlPoint(); placeNewControlPoint();
else else if (lastPoint != null)
beginNewSegment(lastPoint); beginNewSegment(lastPoint);
break; break;
@ -162,9 +158,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void beginNewSegment(PathControlPoint lastPoint) private void beginNewSegment(PathControlPoint lastPoint)
{ {
// Transform the last point into a new segment.
Debug.Assert(lastPoint != null);
segmentStart = lastPoint; segmentStart = lastPoint;
segmentStart.Type = PathType.LINEAR; segmentStart.Type = PathType.LINEAR;
@ -384,7 +377,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
/// </summary> /// </summary>
/// <param name="lastPoint">The last-placed control point. May be null, but is not null if <c>false</c> is returned.</param> /// <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> /// <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. // 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 last = HitObject.Path.ControlPoints.LastOrDefault(p => p != cursor);
@ -408,7 +401,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
if (state == SliderPlacementState.Drawing) if (state == SliderPlacementState.Drawing)
HitObject.Path.ExpectedDistance.Value = (float)HitObject.Path.CalculatedDistance; HitObject.Path.ExpectedDistance.Value = (float)HitObject.Path.CalculatedDistance;
else else
HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? (float)HitObject.Path.CalculatedDistance;
bodyPiece.UpdateFrom(HitObject); bodyPiece.UpdateFrom(HitObject);
headCirclePiece.UpdateFrom(HitObject.HeadCircle); headCirclePiece.UpdateFrom(HitObject.HeadCircle);
@ -436,7 +429,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
// Replace this segment with a circular arc if it is a reasonable substitute. // Replace this segment with a circular arc if it is a reasonable substitute.
var circleArcSegment = tryCircleArc(segment); 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[0], PathType.PERFECT_CURVE));
HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[1])); HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[1]));
@ -453,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; 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. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface; 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 new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject;
protected SliderBodyPiece BodyPiece { get; private set; } protected SliderBodyPiece BodyPiece { get; private set; } = null!;
protected SliderCircleOverlay HeadOverlay { get; private set; } protected SliderCircleOverlay HeadOverlay { get; private set; } = null!;
protected SliderCircleOverlay TailOverlay { get; private set; } 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)] [Resolved]
private IDistanceSnapProvider distanceSnapProvider { get; set; } private IDistanceSnapProvider? distanceSnapProvider { get; set; }
[Resolved(CanBeNull = true)] [Resolved]
private IPlacementHandler placementHandler { get; set; } private IPlacementHandler? placementHandler { get; set; }
[Resolved(CanBeNull = true)] [Resolved]
private EditorBeatmap editorBeatmap { get; set; } private EditorBeatmap? editorBeatmap { get; set; }
[Resolved(CanBeNull = true)] [Resolved]
private IEditorChangeHandler changeHandler { get; set; } private IEditorChangeHandler? changeHandler { get; set; }
[Resolved(CanBeNull = true)] [Resolved]
private BindableBeatDivisor beatDivisor { get; set; } private BindableBeatDivisor? beatDivisor { get; set; }
private PathControlPoint? placementControlPoint;
public override Quad SelectionQuad public override Quad SelectionQuad
{ {
@ -61,6 +62,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{ {
var result = BodyPiece.ScreenSpaceDrawQuad.AABBFloat; var result = BodyPiece.ScreenSpaceDrawQuad.AABBFloat;
result = RectangleF.Union(result, HeadOverlay.VisibleQuad);
result = RectangleF.Union(result, TailOverlay.VisibleQuad);
if (ControlPointVisualiser != null) if (ControlPointVisualiser != null)
{ {
foreach (var piece in ControlPointVisualiser.Pieces) 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 BindableList<HitObject> selectedObjects = new BindableList<HitObject>();
private readonly Bindable<bool> showHitMarkers = new Bindable<bool>(); 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) public SliderSelectionBlueprint(Slider slider)
: base(slider) : base(slider)
{ {
@ -91,6 +100,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End), 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); config.BindWith(OsuSetting.EditorShowHitMarkers, showHitMarkers);
} }
@ -99,6 +115,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
base.LoadComplete(); base.LoadComplete();
controlPoints.BindTo(HitObject.Path.ControlPoints); controlPoints.BindTo(HitObject.Path.ControlPoints);
controlPoints.CollectionChanged += (_, _) => fullPathCache.Invalidate();
pathVersion.BindTo(HitObject.Path.Version); pathVersion.BindTo(HitObject.Path.Version);
pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject)); pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject));
@ -123,7 +140,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return false; return false;
hoveredControlPoint.IsSelected.Value = true; hoveredControlPoint.IsSelected.Value = true;
ControlPointVisualiser.DeleteSelected(); ControlPointVisualiser?.DeleteSelected();
return true; return true;
} }
@ -141,7 +158,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)
{ {
updateVisualDefinition(); updateVisualDefinition();
return base.OnHover(e); 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) protected override bool OnMouseDown(MouseDownEvent e)
{ {
switch (e.Button) switch (e.Button)
{ {
case MouseButton.Right: case MouseButton.Right:
rightClickPosition = e.MouseDownPosition; lastRightClickPosition = e.MouseDownPosition;
return false; // Allow right click to be handled by context menu return false; // Allow right click to be handled by context menu
case MouseButton.Left: case MouseButton.Left:
// If there's more than two objects selected, ctrl+click should deselect // If there's more than two objects selected, ctrl+click should deselect
if (e.ControlPressed && IsSelected && selectedObjects.Count < 2) if (e.ControlPressed && IsSelected && selectedObjects.Count < 2)
{ {
@ -212,8 +227,134 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return false; return false;
} }
[CanBeNull] #region Length Adjustment (independent of path nodes)
private PathControlPoint placementControlPoint;
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, DistanceSnapTarget.Start) ?? 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) protected override bool OnDragStart(DragStartEvent e)
{ {
@ -255,9 +396,24 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return true; return true;
} }
if (isAdjustingLength && e.ShiftPressed != adjustVelocityMomentary)
{
adjustVelocityMomentary = e.ShiftPressed;
adjustLength(desiredDistance, adjustVelocityMomentary);
return true;
}
return false; 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) private PathControlPoint addControlPoint(Vector2 position)
{ {
position -= HitObject.Position; position -= HitObject.Position;
@ -326,6 +482,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void splitControlPoints(List<PathControlPoint> controlPointsToSplitAt) private void splitControlPoints(List<PathControlPoint> controlPointsToSplitAt)
{ {
if (editorBeatmap == null)
return;
// Arbitrary gap in milliseconds to put between split slider pieces // Arbitrary gap in milliseconds to put between split slider pieces
const double split_gap = 100; const double split_gap = 100;
@ -432,7 +591,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
new OsuMenuItem("Add control point", MenuItemType.Standard, () => new OsuMenuItem("Add control point", MenuItemType.Standard, () =>
{ {
changeHandler?.BeginChange(); changeHandler?.BeginChange();
addControlPoint(rightClickPosition); addControlPoint(lastRightClickPosition);
changeHandler?.EndChange(); changeHandler?.EndChange();
}), }),
new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream), new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream),

View File

@ -13,7 +13,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners
{ {
public partial class SpinnerPlacementBlueprint : PlacementBlueprint public partial class SpinnerPlacementBlueprint : HitObjectPlacementBlueprint
{ {
public new Spinner HitObject => (Spinner)base.HitObject; public new Spinner HitObject => (Spinner)base.HitObject;

View File

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

View File

@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override DrawableRuleset<OsuHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) protected override DrawableRuleset<OsuHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new DrawableOsuEditorRuleset(ruleset, beatmap, mods); => new DrawableOsuEditorRuleset(ruleset, beatmap, mods);
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[] protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{ {
new HitCircleCompositionTool(), new HitCircleCompositionTool(),
new SliderCompositionTool(), new SliderCompositionTool(),
@ -54,11 +54,8 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override IEnumerable<TernaryButton> CreateTernaryButtons() protected override IEnumerable<TernaryButton> CreateTernaryButtons()
=> base.CreateTernaryButtons() => base.CreateTernaryButtons()
.Concat(DistanceSnapProvider.CreateTernaryButtons()) .Append(new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap }))
.Concat(new[] .Concat(DistanceSnapProvider.CreateTernaryButtons());
{
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap })
});
private BindableList<HitObject> selectedHitObjects; private BindableList<HitObject> selectedHitObjects;
@ -109,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler, RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler,
ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler, ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler,
GridToolbox = OsuGridToolboxGroup,
}, },
new GenerateToolboxGroup(), new GenerateToolboxGroup(),
FreehandSliderToolboxGroup FreehandSliderToolboxGroup
@ -371,6 +369,8 @@ namespace osu.Game.Rulesets.Osu.Edit
gridSnapMomentary = shiftPressed; gridSnapMomentary = shiftPressed;
rectangularGridSnapToggle.Value = rectangularGridSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; rectangularGridSnapToggle.Value = rectangularGridSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False;
} }
DistanceSnapProvider.HandleToggleViaKey(key);
} }
private DistanceSnapGrid createDistanceSnapGrid(IEnumerable<HitObject> selectedHitObjects) private DistanceSnapGrid createDistanceSnapGrid(IEnumerable<HitObject> selectedHitObjects)

View File

@ -3,6 +3,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
@ -25,6 +26,9 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
public partial class OsuSelectionHandler : EditorSelectionHandler public partial class OsuSelectionHandler : EditorSelectionHandler
{ {
[Resolved]
private OsuGridToolboxGroup gridToolbox { get; set; } = null!;
protected override void OnSelectionChanged() protected override void OnSelectionChanged()
{ {
base.OnSelectionChanged(); base.OnSelectionChanged();
@ -123,13 +127,43 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
var hitObjects = selectedMovableObjects; var hitObjects = selectedMovableObjects;
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : GeometryUtils.GetSurroundingQuad(hitObjects); // If we're flipping over the origin, we take the grid origin position from the grid toolbox.
var flipQuad = flipOverOrigin ? new Quad(gridToolbox.StartPositionX.Value, gridToolbox.StartPositionY.Value, 0, 0) : GeometryUtils.GetSurroundingQuad(hitObjects);
Vector2 flipAxis = direction == Direction.Vertical ? Vector2.UnitY : Vector2.UnitX;
if (flipOverOrigin)
{
// If we're flipping over the origin, we take one of the axes of the grid.
// Take the axis closest to the direction we want to flip over.
switch (gridToolbox.GridType.Value)
{
case PositionSnapGridType.Square:
flipAxis = GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 45) % 90 - 45));
flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis;
break;
case PositionSnapGridType.Triangle:
// Hex grid has 3 axes, so you can not directly flip over one of the axes,
// however it's still possible to achieve that flip by combining multiple flips over the other axes.
// Angle degree range for vertical = (-120, -60]
// Angle degree range for horizontal = [-30, 30)
flipAxis = direction == Direction.Vertical
? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 30) % 60 + 60))
: GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30));
break;
}
}
var controlPointFlipQuad = new Quad();
bool didFlip = false; bool didFlip = false;
foreach (var h in hitObjects) foreach (var h in hitObjects)
{ {
var flippedPosition = GeometryUtils.GetFlippedPosition(direction, flipQuad, h.Position); var flippedPosition = GeometryUtils.GetFlippedPosition(flipAxis, flipQuad, h.Position);
// Clamp the flipped position inside the playfield bounds, because the flipped position might be outside the playfield bounds if the origin is not centered.
flippedPosition = Vector2.Clamp(flippedPosition, Vector2.Zero, OsuPlayfield.BASE_SIZE);
if (!Precision.AlmostEquals(flippedPosition, h.Position)) if (!Precision.AlmostEquals(flippedPosition, h.Position))
{ {
@ -142,12 +176,7 @@ namespace osu.Game.Rulesets.Osu.Edit
didFlip = true; didFlip = true;
foreach (var cp in slider.Path.ControlPoints) foreach (var cp in slider.Path.ControlPoints)
{ cp.Position = GeometryUtils.GetFlippedPosition(flipAxis, controlPointFlipQuad, cp.Position);
cp.Position = new Vector2(
(direction == Direction.Horizontal ? -1 : 1) * cp.Position.X,
(direction == Direction.Vertical ? -1 : 1) * cp.Position.Y
);
}
} }
} }

View File

@ -47,7 +47,6 @@ namespace osu.Game.Rulesets.Osu.Edit
private OsuHitObject[]? objectsInRotation; private OsuHitObject[]? objectsInRotation;
private Vector2? defaultOrigin;
private Dictionary<OsuHitObject, Vector2>? originalPositions; private Dictionary<OsuHitObject, Vector2>? originalPositions;
private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions; private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions;
@ -61,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit
changeHandler?.BeginChange(); changeHandler?.BeginChange();
objectsInRotation = selectedMovableObjects.ToArray(); objectsInRotation = selectedMovableObjects.ToArray();
defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation).Centre; DefaultOrigin = GeometryUtils.MinimumEnclosingCircle(objectsInRotation).Item1;
originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position); originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position);
originalPathControlPointPositions = objectsInRotation.OfType<IHasPath>().ToDictionary( originalPathControlPointPositions = objectsInRotation.OfType<IHasPath>().ToDictionary(
obj => obj, obj => obj,
@ -73,9 +72,9 @@ namespace osu.Game.Rulesets.Osu.Edit
if (!OperationInProgress.Value) if (!OperationInProgress.Value)
throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!"); throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!");
Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null); Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && DefaultOrigin != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value; Vector2 actualOrigin = origin ?? DefaultOrigin.Value;
foreach (var ho in objectsInRotation) foreach (var ho in objectsInRotation)
{ {
@ -103,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Edit
objectsInRotation = null; objectsInRotation = null;
originalPositions = null; originalPositions = null;
originalPathControlPointPositions = null; originalPathControlPointPositions = null;
defaultOrigin = null; DefaultOrigin = null;
} }
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>() private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()

View File

@ -69,6 +69,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private Dictionary<OsuHitObject, OriginalHitObjectState>? objectsInScale; private Dictionary<OsuHitObject, OriginalHitObjectState>? objectsInScale;
private Vector2? defaultOrigin; private Vector2? defaultOrigin;
private List<Vector2>? originalConvexHull;
public override void Begin() public override void Begin()
{ {
@ -83,10 +84,13 @@ namespace osu.Game.Rulesets.Osu.Edit
OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position)) ? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position))
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys); : GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
defaultOrigin = OriginalSurroundingQuad.Value.Centre; originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2
? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position))
: GeometryUtils.GetConvexHull(objectsInScale.Keys);
defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1;
} }
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both) public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{ {
if (!OperationInProgress.Value) if (!OperationInProgress.Value)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");
@ -94,23 +98,22 @@ namespace osu.Game.Rulesets.Osu.Edit
Debug.Assert(objectsInScale != null && defaultOrigin != null && OriginalSurroundingQuad != null); Debug.Assert(objectsInScale != null && defaultOrigin != null && OriginalSurroundingQuad != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value; Vector2 actualOrigin = origin ?? defaultOrigin.Value;
scale = clampScaleToAdjustAxis(scale, adjustAxis);
// for the time being, allow resizing of slider paths only if the slider is // for the time being, allow resizing of slider paths only if the slider is
// the only hit object selected. with a group selection, it's likely the user // the only hit object selected. with a group selection, it's likely the user
// is not looking to change the duration of the slider but expand the whole pattern. // is not looking to change the duration of the slider but expand the whole pattern.
if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider) if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
{ {
var originalInfo = objectsInScale[slider]; scaleSlider(slider, scale, actualOrigin, objectsInScale[slider], axisRotation);
Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null);
scaleSlider(slider, scale, originalInfo.PathControlPointPositions, originalInfo.PathControlPointTypes);
} }
else else
{ {
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin); scale = ClampScaleToPlayfieldBounds(scale, actualOrigin, adjustAxis, axisRotation);
foreach (var (ho, originalState) in objectsInScale) foreach (var (ho, originalState) in objectsInScale)
{ {
ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position); ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position, axisRotation);
} }
} }
@ -134,21 +137,45 @@ namespace osu.Game.Rulesets.Osu.Edit
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>() private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
.Where(h => h is not Spinner); .Where(h => h is not Spinner);
private void scaleSlider(Slider slider, Vector2 scale, Vector2[] originalPathPositions, PathType?[] originalPathTypes) private Vector2 clampScaleToAdjustAxis(Vector2 scale, Axes adjustAxis)
{ {
switch (adjustAxis)
{
case Axes.Y:
scale.X = 1;
break;
case Axes.X:
scale.Y = 1;
break;
case Axes.None:
scale = Vector2.One;
break;
}
return scale;
}
private void scaleSlider(Slider slider, Vector2 scale, Vector2 origin, OriginalHitObjectState originalInfo, float axisRotation = 0)
{
Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null);
scale = Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); scale = Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
// Maintain the path types in case they were defaulted to bezier at some point during scaling // Maintain the path types in case they were defaulted to bezier at some point during scaling
for (int i = 0; i < slider.Path.ControlPoints.Count; i++) for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
{ {
slider.Path.ControlPoints[i].Position = originalPathPositions[i] * scale; slider.Path.ControlPoints[i].Position = GeometryUtils.GetScaledPosition(scale, Vector2.Zero, originalInfo.PathControlPointPositions[i], axisRotation);
slider.Path.ControlPoints[i].Type = originalPathTypes[i]; slider.Path.ControlPoints[i].Type = originalInfo.PathControlPointTypes[i];
} }
// Snap the slider's length to the current beat divisor // Snap the slider's length to the current beat divisor
// to calculate the final resulting duration / bounding box before the final checks. // to calculate the final resulting duration / bounding box before the final checks.
slider.SnapTo(snapProvider); slider.SnapTo(snapProvider);
slider.Position = GeometryUtils.GetScaledPosition(scale, origin, originalInfo.Position, axisRotation);
//if sliderhead or sliderend end up outside playfield, revert scaling. //if sliderhead or sliderend end up outside playfield, revert scaling.
Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider }); Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider });
(bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
@ -157,7 +184,9 @@ namespace osu.Game.Rulesets.Osu.Edit
return; return;
for (int i = 0; i < slider.Path.ControlPoints.Count; i++) for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
slider.Path.ControlPoints[i].Position = originalPathPositions[i]; slider.Path.ControlPoints[i].Position = originalInfo.PathControlPointPositions[i];
slider.Position = originalInfo.Position;
// Snap the slider's length again to undo the potentially-invalid length applied by the previous snap. // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap.
slider.SnapTo(snapProvider); slider.SnapTo(snapProvider);
@ -176,11 +205,13 @@ namespace osu.Game.Rulesets.Osu.Edit
/// </summary> /// </summary>
/// <param name="origin">The origin from which the scale operation is performed</param> /// <param name="origin">The origin from which the scale operation is performed</param>
/// <param name="scale">The scale to be clamped</param> /// <param name="scale">The scale to be clamped</param>
/// <param name="adjustAxis">The axes to adjust the scale in.</param>
/// <param name="axisRotation">The rotation of the axes in degrees</param>
/// <returns>The clamped scale vector</returns> /// <returns>The clamped scale vector</returns>
public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null) public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{ {
//todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead. //todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
if (objectsInScale == null) if (objectsInScale == null || adjustAxis == Axes.None)
return scale; return scale;
Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null); Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null);
@ -188,24 +219,60 @@ namespace osu.Game.Rulesets.Osu.Edit
if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider) if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
origin = slider.Position; origin = slider.Position;
float cos = MathF.Cos(float.DegreesToRadians(-axisRotation));
float sin = MathF.Sin(float.DegreesToRadians(-axisRotation));
scale = clampScaleToAdjustAxis(scale, adjustAxis);
Vector2 actualOrigin = origin ?? defaultOrigin.Value; Vector2 actualOrigin = origin ?? defaultOrigin.Value;
IEnumerable<Vector2> points;
if (axisRotation == 0)
{
var selectionQuad = OriginalSurroundingQuad.Value; var selectionQuad = OriginalSurroundingQuad.Value;
points = new[]
{
selectionQuad.TopLeft,
selectionQuad.TopRight,
selectionQuad.BottomLeft,
selectionQuad.BottomRight
};
}
else
points = originalConvexHull!;
var tl1 = Vector2.Divide(-actualOrigin, selectionQuad.TopLeft - actualOrigin); foreach (var point in points)
var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.TopLeft - actualOrigin); {
var br1 = Vector2.Divide(-actualOrigin, selectionQuad.BottomRight - actualOrigin); scale = clampToBound(scale, point, Vector2.Zero);
var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.BottomRight - actualOrigin); scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
}
if (!Precision.AlmostEquals(selectionQuad.TopLeft.X - actualOrigin.X, 0))
scale.X = selectionQuad.TopLeft.X - actualOrigin.X < 0 ? MathHelper.Clamp(scale.X, tl2.X, tl1.X) : MathHelper.Clamp(scale.X, tl1.X, tl2.X);
if (!Precision.AlmostEquals(selectionQuad.TopLeft.Y - actualOrigin.Y, 0))
scale.Y = selectionQuad.TopLeft.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, tl2.Y, tl1.Y) : MathHelper.Clamp(scale.Y, tl1.Y, tl2.Y);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.X - actualOrigin.X, 0))
scale.X = selectionQuad.BottomRight.X - actualOrigin.X < 0 ? MathHelper.Clamp(scale.X, br2.X, br1.X) : MathHelper.Clamp(scale.X, br1.X, br2.X);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.Y - actualOrigin.Y, 0))
scale.Y = selectionQuad.BottomRight.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, br2.Y, br1.Y) : MathHelper.Clamp(scale.Y, br1.Y, br2.Y);
return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
float minPositiveComponent(Vector2 v) => MathF.Min(v.X < 0 ? float.PositiveInfinity : v.X, v.Y < 0 ? float.PositiveInfinity : v.Y);
Vector2 clampToBound(Vector2 s, Vector2 p, Vector2 bound)
{
p -= actualOrigin;
bound -= actualOrigin;
var a = new Vector2(cos * cos * p.X - sin * cos * p.Y, -sin * cos * p.X + sin * sin * p.Y);
var b = new Vector2(sin * sin * p.X + sin * cos * p.Y, sin * cos * p.X + cos * cos * p.Y);
switch (adjustAxis)
{
case Axes.X:
s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a)));
break;
case Axes.Y:
s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b)));
break;
case Axes.Both:
s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y)));
break;
}
return s;
}
} }
private void moveSelectionInBounds() private void moveSelectionInBounds()

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -19,16 +20,19 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
private readonly SelectionRotationHandler rotationHandler; private readonly SelectionRotationHandler rotationHandler;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre)); private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.GridCentre));
private SliderWithTextBoxInput<float> angleInput = null!; private SliderWithTextBoxInput<float> angleInput = null!;
private EditorRadioButtonCollection rotationOrigin = null!; private EditorRadioButtonCollection rotationOrigin = null!;
private RadioButton selectionCentreButton = null!; private RadioButton selectionCentreButton = null!;
public PreciseRotationPopover(SelectionRotationHandler rotationHandler) public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox)
{ {
this.rotationHandler = rotationHandler; this.rotationHandler = rotationHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
} }
@ -58,6 +62,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Items = new[] Items = new[]
{ {
new RadioButton("Grid centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
new RadioButton("Playfield centre", new RadioButton("Playfield centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }), () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -93,10 +100,19 @@ namespace osu.Game.Rulesets.Osu.Edit
rotationInfo.BindValueChanged(rotation => rotationInfo.BindValueChanged(rotation =>
{ {
rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null); rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue));
}); });
} }
private Vector2? getOriginPosition(PreciseRotationInfo rotation) =>
rotation.Origin switch
{
RotationOrigin.GridCentre => gridToolbox.StartPosition.Value,
RotationOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
RotationOrigin.SelectionCentre => null,
_ => throw new ArgumentOutOfRangeException(nameof(rotation))
};
protected override void PopIn() protected override void PopIn()
{ {
base.PopIn(); base.PopIn();
@ -114,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum RotationOrigin public enum RotationOrigin
{ {
GridCentre,
PlayfieldCentre, PlayfieldCentre,
SelectionCentre SelectionCentre
} }

View File

@ -10,7 +10,10 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Components.RadioButtons;
using osuTK; using osuTK;
@ -20,28 +23,36 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
private readonly OsuSelectionScaleHandler scaleHandler; private readonly OsuSelectionScaleHandler scaleHandler;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.PlayfieldCentre, true, true)); private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.GridCentre, true, true));
private SliderWithTextBoxInput<float> scaleInput = null!; private SliderWithTextBoxInput<float> scaleInput = null!;
private BindableNumber<float> scaleInputBindable = null!; private BindableNumber<float> scaleInputBindable = null!;
private EditorRadioButtonCollection scaleOrigin = null!; private EditorRadioButtonCollection scaleOrigin = null!;
private RadioButton gridCentreButton = null!;
private RadioButton playfieldCentreButton = null!; private RadioButton playfieldCentreButton = null!;
private RadioButton selectionCentreButton = null!; private RadioButton selectionCentreButton = null!;
private OsuCheckbox xCheckBox = null!; private OsuCheckbox xCheckBox = null!;
private OsuCheckbox yCheckBox = null!; private OsuCheckbox yCheckBox = null!;
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler) private BindableList<HitObject> selectedItems { get; } = new BindableList<HitObject>();
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox)
{ {
this.scaleHandler = scaleHandler; this.scaleHandler = scaleHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(EditorBeatmap editorBeatmap)
{ {
selectedItems.BindTo(editorBeatmap.SelectedHitObjects);
Child = new FillFlowContainer Child = new FillFlowContainer
{ {
Width = 220, Width = 220,
@ -66,6 +77,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Items = new[] Items = new[]
{ {
gridCentreButton = new RadioButton("Grid centre",
() => setOrigin(ScaleOrigin.GridCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
playfieldCentreButton = new RadioButton("Playfield centre", playfieldCentreButton = new RadioButton("Playfield centre",
() => setOrigin(ScaleOrigin.PlayfieldCentre), () => setOrigin(ScaleOrigin.PlayfieldCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }), () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -97,6 +111,10 @@ namespace osu.Game.Rulesets.Osu.Edit
}, },
} }
}; };
gridCentreButton.Selected.DisabledChanged += isDisabled =>
{
gridCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to grid centre." : string.Empty;
};
playfieldCentreButton.Selected.DisabledChanged += isDisabled => playfieldCentreButton.Selected.DisabledChanged += isDisabled =>
{ {
playfieldCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to playfield centre." : string.Empty; playfieldCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to playfield centre." : string.Empty;
@ -123,19 +141,20 @@ namespace osu.Game.Rulesets.Osu.Edit
selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value); selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled; playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled;
scaleOrigin.Items.First(b => !b.Selected.Disabled).Select(); scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();
scaleInfo.BindValueChanged(scale => scaleInfo.BindValueChanged(scale =>
{ {
var newScale = new Vector2(scale.NewValue.XAxis ? scale.NewValue.Scale : 1, scale.NewValue.YAxis ? scale.NewValue.Scale : 1); var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale);
scaleHandler.Update(newScale, getOriginPosition(scale.NewValue)); scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), getRotation(scale.NewValue));
}); });
} }
private void updateAxisCheckBoxesEnabled() private void updateAxisCheckBoxesEnabled()
{ {
if (scaleInfo.Value.Origin == ScaleOrigin.PlayfieldCentre) if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
{ {
toggleAxisAvailable(xCheckBox.Current, true); toggleAxisAvailable(xCheckBox.Current, true);
toggleAxisAvailable(yCheckBox.Current, true); toggleAxisAvailable(yCheckBox.Current, true);
@ -162,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return; return;
const float max_scale = 10; const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value)); var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis) if (!scaleInfo.Value.XAxis)
scale.X = max_scale; scale.X = max_scale;
@ -179,7 +198,30 @@ namespace osu.Game.Rulesets.Osu.Edit
updateAxisCheckBoxesEnabled(); updateAxisCheckBoxesEnabled();
} }
private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null; private Vector2? getOriginPosition(PreciseScaleInfo scale)
{
switch (scale.Origin)
{
case ScaleOrigin.GridCentre:
return gridToolbox.StartPosition.Value;
case ScaleOrigin.PlayfieldCentre:
return OsuPlayfield.BASE_SIZE / 2;
case ScaleOrigin.SelectionCentre:
if (selectedItems.Count == 1 && selectedItems.First() is Slider slider)
return slider.Position;
return null;
default:
throw new ArgumentOutOfRangeException(nameof(scale));
}
}
private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y;
private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
private void setAxis(bool x, bool y) private void setAxis(bool x, bool y)
{ {
@ -204,6 +246,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum ScaleOrigin public enum ScaleOrigin
{ {
GridCentre,
PlayfieldCentre, PlayfieldCentre,
SelectionCentre SelectionCentre
} }

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
{ {
public class SliderCompositionTool : HitObjectCompositionTool public class SliderCompositionTool : CompositionTool
{ {
public SliderCompositionTool() public SliderCompositionTool()
: base(nameof(Slider)) : base(nameof(Slider))
@ -26,6 +26,6 @@ namespace osu.Game.Rulesets.Osu.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint(); public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint();
} }
} }

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
{ {
public class SpinnerCompositionTool : HitObjectCompositionTool public class SpinnerCompositionTool : CompositionTool
{ {
public SpinnerCompositionTool() public SpinnerCompositionTool()
: base(nameof(Spinner)) : base(nameof(Spinner))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Osu.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override PlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint(); public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint();
} }
} }

View File

@ -27,6 +27,8 @@ namespace osu.Game.Rulesets.Osu.Edit
public SelectionRotationHandler RotationHandler { get; init; } = null!; public SelectionRotationHandler RotationHandler { get; init; } = null!;
public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!; public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!;
public OsuGridToolboxGroup GridToolbox { get; init; } = null!;
public TransformToolboxGroup() public TransformToolboxGroup()
: base("transform") : base("transform")
{ {
@ -44,10 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
rotateButton = new EditorToolButton("Rotate", rotateButton = new EditorToolButton("Rotate",
() => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, () => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
() => new PreciseRotationPopover(RotationHandler)), () => new PreciseRotationPopover(RotationHandler, GridToolbox)),
scaleButton = new EditorToolButton("Scale", scaleButton = new EditorToolButton("Scale",
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt },
() => new PreciseScalePopover(ScaleHandler)) () => new PreciseScalePopover(ScaleHandler, GridToolbox))
} }
}; };
} }

View File

@ -91,19 +91,34 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableHitObject; drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableHitObject; drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableHitObject;
} }
else
// but at the end apply the transforms now regardless of whether this is a DHO or not.
// the above is just to ensure they don't get overwritten later.
applyDim(piece); applyDim(piece);
} }
}
void applyDim(Drawable piece) protected override void ClearNestedHitObjects()
{
base.ClearNestedHitObjects();
// any dimmable pieces that are DHOs will be pooled separately.
// `applyDimToDrawableHitObject` is a closure that implicitly captures `this`,
// and because of separate pooling of parent and child objects, there is no guarantee that the pieces will be associated with `this` again on re-use.
// therefore, clean up the subscription here to avoid crosstalk.
// not doing so can result in the callback attempting to read things from `this` when it is in a completely bogus state (not in use or similar).
foreach (var piece in DimmablePieces.OfType<DrawableHitObject>())
piece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
}
private void applyDim(Drawable piece)
{ {
piece.FadeColour(new Color4(195, 195, 195, 255)); piece.FadeColour(new Color4(195, 195, 195, 255));
using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
piece.FadeColour(Color4.White, 100); piece.FadeColour(Color4.White, 100);
} }
void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho); private void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho);
}
protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt;

View File

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

View File

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

View File

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

View File

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

View File

@ -44,23 +44,19 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
switch (lookup) switch (lookup)
{ {
case SkinComponentsContainerLookup containerLookup: case GlobalSkinnableContainerLookup containerLookup:
// Only handle per ruleset defaults here. // Only handle per ruleset defaults here.
if (containerLookup.Ruleset == null) if (containerLookup.Ruleset == null)
return base.GetDrawableComponent(lookup); return base.GetDrawableComponent(lookup);
// Skin has configuration.
if (base.GetDrawableComponent(lookup) is UserConfiguredLayoutContainer d)
return d;
// we don't have enough assets to display these components (this is especially the case on a "beatmap" skin). // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin).
if (!IsProvidingLegacyResources) if (!IsProvidingLegacyResources)
return null; return null;
// Our own ruleset components default. // Our own ruleset components default.
switch (containerLookup.Target) switch (containerLookup.Lookup)
{ {
case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: case GlobalSkinnableContainers.MainHUDComponents:
return new DefaultSkinComponentsContainer(container => return new DefaultSkinComponentsContainer(container =>
{ {
var keyCounter = container.OfType<LegacyKeyCounterDisplay>().FirstOrDefault(); var keyCounter = container.OfType<LegacyKeyCounterDisplay>().FirstOrDefault();
@ -69,10 +65,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
// set the anchor to top right so that it won't squash to the return button to the top // set the anchor to top right so that it won't squash to the return button to the top
keyCounter.Anchor = Anchor.CentreRight; keyCounter.Anchor = Anchor.CentreRight;
keyCounter.Origin = Anchor.CentreRight; keyCounter.Origin = Anchor.TopRight;
keyCounter.X = 0; keyCounter.Position = new Vector2(0, -40) * 1.6f;
// 340px is the default height inherit from stable
keyCounter.Y = container.ToLocalSpace(new Vector2(0, container.ScreenSpaceDrawQuad.Centre.Y - 340f)).Y;
} }
var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault(); var combo = container.OfType<LegacyDefaultComboCounter>().FirstOrDefault();

View File

@ -38,6 +38,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
private double timeOffset; private double timeOffset;
private float time; 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; private Anchor trailOrigin = Anchor.Centre;
protected Anchor TrailOrigin protected Anchor TrailOrigin
@ -188,6 +193,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
{ {
parts[currentIndex].Position = localSpacePosition; parts[currentIndex].Position = localSpacePosition;
parts[currentIndex].Time = time + 1; parts[currentIndex].Time = time + 1;
parts[currentIndex].Scale = NewPartScale;
++parts[currentIndex].InvalidationID; ++parts[currentIndex].InvalidationID;
currentIndex = (currentIndex + 1) % max_sprites; currentIndex = (currentIndex + 1) % max_sprites;
@ -199,6 +205,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
{ {
public Vector2 Position; public Vector2 Position;
public float Time; public float Time;
public Vector2 Scale;
public long InvalidationID; public long InvalidationID;
} }
@ -280,7 +287,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex vertexBatch.Add(new TexturedTrailVertex
{ {
Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X, part.Position.Y + texture.DisplayHeight * (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, TexturePosition = textureRect.BottomLeft,
TextureRect = new Vector4(0, 0, 1, 1), TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.BottomLeft.Linear, Colour = DrawColourInfo.Colour.BottomLeft.Linear,
@ -289,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex vertexBatch.Add(new TexturedTrailVertex
{ {
Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X), part.Position.Y + texture.DisplayHeight * (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, TexturePosition = textureRect.BottomRight,
TextureRect = new Vector4(0, 0, 1, 1), TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.BottomRight.Linear, Colour = DrawColourInfo.Colour.BottomRight.Linear,
@ -298,7 +305,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex vertexBatch.Add(new TexturedTrailVertex
{ {
Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X), part.Position.Y - texture.DisplayHeight * 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, TexturePosition = textureRect.TopRight,
TextureRect = new Vector4(0, 0, 1, 1), TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.TopRight.Linear, Colour = DrawColourInfo.Colour.TopRight.Linear,
@ -307,7 +314,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
vertexBatch.Add(new TexturedTrailVertex vertexBatch.Add(new TexturedTrailVertex
{ {
Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X, part.Position.Y - texture.DisplayHeight * 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, TexturePosition = textureRect.TopLeft,
TextureRect = new Vector4(0, 0, 1, 1), TextureRect = new Vector4(0, 0, 1, 1),
Colour = DrawColourInfo.Colour.TopLeft.Linear, Colour = DrawColourInfo.Colour.TopLeft.Linear,

View File

@ -31,6 +31,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
private SkinnableCursor skinnableCursor => (SkinnableCursor)cursorSprite.Drawable; 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; public IBindable<float> CursorScale => cursorScale;
private readonly Bindable<float> cursorScale = new BindableFloat(1); private readonly Bindable<float> cursorScale = new BindableFloat(1);

View File

@ -23,14 +23,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
public new OsuCursor ActiveCursor => (OsuCursor)base.ActiveCursor; public new OsuCursor ActiveCursor => (OsuCursor)base.ActiveCursor;
protected override Drawable CreateCursor() => new OsuCursor(); protected override Drawable CreateCursor() => new OsuCursor();
protected override Container<Drawable> Content => fadeContainer; protected override Container<Drawable> Content => fadeContainer;
private readonly Container<Drawable> fadeContainer; private readonly Container<Drawable> fadeContainer;
private readonly Bindable<bool> showTrail = new Bindable<bool>(true); private readonly Bindable<bool> showTrail = new Bindable<bool>(true);
private readonly Drawable cursorTrail; private readonly SkinnableDrawable cursorTrail;
private readonly CursorRippleVisualiser rippleVisualiser; private readonly CursorRippleVisualiser rippleVisualiser;
@ -39,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
InternalChild = fadeContainer = new Container InternalChild = fadeContainer = new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new[] Children = new CompositeDrawable[]
{ {
cursorTrail = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling), cursorTrail = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling),
rippleVisualiser = new CursorRippleVisualiser(), rippleVisualiser = new CursorRippleVisualiser(),
@ -79,6 +78,14 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
ActiveCursor.Contract(); ActiveCursor.Contract();
} }
protected override void Update()
{
base.Update();
if (cursorTrail.Drawable is CursorTrail trail)
trail.NewPartScale = ActiveCursor.CurrentExpandedScale;
}
public bool OnPressed(KeyBindingPressEvent<OsuAction> e) public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
{ {
switch (e.Action) switch (e.Action)

View File

@ -1,11 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Input.Handlers; using osu.Game.Input.Handlers;
@ -25,18 +26,38 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
public partial class DrawableOsuRuleset : DrawableRuleset<OsuHitObject> public partial class DrawableOsuRuleset : DrawableRuleset<OsuHitObject>
{ {
protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; private Bindable<bool>? cursorHideEnabled;
public new OsuInputManager KeyBindingInputManager => (OsuInputManager)base.KeyBindingInputManager; public new OsuInputManager KeyBindingInputManager => (OsuInputManager)base.KeyBindingInputManager;
public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield; public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield;
public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null) protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config;
public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
: base(ruleset, beatmap, mods) : base(ruleset, beatmap, mods)
{ {
} }
public override DrawableHitObject<OsuHitObject> CreateDrawableRepresentation(OsuHitObject h) => null; [BackgroundDependencyLoader]
private void load(ReplayPlayer? replayPlayer)
{
if (replayPlayer != null)
{
ReplayAnalysisOverlay analysisOverlay;
PlayfieldAdjustmentContainer.Add(analysisOverlay = new ReplayAnalysisOverlay(replayPlayer.Score.Replay));
Overlays.Add(analysisOverlay.CreateProxy().With(p => p.Depth = float.NegativeInfinity));
replayPlayer.AddSettings(new ReplayAnalysisSettings(Config));
cursorHideEnabled = Config.GetBindable<bool>(OsuRulesetSetting.ReplayCursorHideEnabled);
// I have little faith in this working (other things touch cursor visibility) but haven't broken it yet.
// Let's wait for someone to report an issue before spending too much time on it.
cursorHideEnabled.BindValueChanged(enabled => Playfield.Cursor.FadeTo(enabled.NewValue ? 0 : 1), true);
}
}
public override DrawableHitObject<OsuHitObject>? CreateDrawableRepresentation(OsuHitObject h) => null;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // always show the gameplay cursor public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // always show the gameplay cursor

View File

@ -3,6 +3,7 @@
using System; using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
@ -35,9 +36,11 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
OsuResumeOverlayInputBlocker? inputBlocker = null; OsuResumeOverlayInputBlocker? inputBlocker = null;
if (drawableRuleset != null) var drawableOsuRuleset = (DrawableOsuRuleset?)drawableRuleset;
if (drawableOsuRuleset != null)
{ {
var osuPlayfield = (OsuPlayfield)drawableRuleset.Playfield; var osuPlayfield = drawableOsuRuleset.Playfield;
osuPlayfield.AttachResumeOverlayInputBlocker(inputBlocker = new OsuResumeOverlayInputBlocker()); osuPlayfield.AttachResumeOverlayInputBlocker(inputBlocker = new OsuResumeOverlayInputBlocker());
} }
@ -45,13 +48,14 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
Child = clickToResumeCursor = new OsuClickToResumeCursor Child = clickToResumeCursor = new OsuClickToResumeCursor
{ {
ResumeRequested = () => ResumeRequested = action =>
{ {
// since the user had to press a button to tap the resume cursor, // since the user had to press a button to tap the resume cursor,
// block that press event from potentially reaching a hit circle that's behind the cursor. // block that press event from potentially reaching a hit circle that's behind the cursor.
// we cannot do this from OsuClickToResumeCursor directly since we're in a different input manager tree than the gameplay one, // we cannot do this from OsuClickToResumeCursor directly since we're in a different input manager tree than the gameplay one,
// so we rely on a dedicated input blocking component that's implanted in there to do that for us. // so we rely on a dedicated input blocking component that's implanted in there to do that for us.
if (inputBlocker != null) // note this only matters when the user didn't pause while they were holding the same key that they are resuming with.
if (inputBlocker != null && !drawableOsuRuleset.AsNonNull().KeyBindingInputManager.PressedActions.Contains(action))
inputBlocker.BlockNextPress = true; inputBlocker.BlockNextPress = true;
Resume(); Resume();
@ -94,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
public override bool HandlePositionalInput => true; public override bool HandlePositionalInput => true;
public Action? ResumeRequested; public Action<OsuAction>? ResumeRequested;
private Container scaleTransitionContainer = null!; private Container scaleTransitionContainer = null!;
public OsuClickToResumeCursor() public OsuClickToResumeCursor()
@ -136,7 +140,7 @@ namespace osu.Game.Rulesets.Osu.UI
return false; return false;
scaleTransitionContainer.ScaleTo(2, TRANSITION_TIME, Easing.OutQuint); scaleTransitionContainer.ScaleTo(2, TRANSITION_TIME, Easing.OutQuint);
ResumeRequested?.Invoke(); ResumeRequested?.Invoke(e.Action);
return true; return true;
} }

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