1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-16 09:22:58 +08:00

Merge branch 'master' into ModCustomisationHeaderColor

This commit is contained in:
Dean Herbert 2024-09-27 18:15:30 +09:00
commit 64c1ced80d
No known key found for this signature in database
183 changed files with 4057 additions and 1061 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.904.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2024.916.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

@ -95,11 +95,11 @@ 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();
return new VelopackUpdateManager(); return new VelopackUpdateManager();

View File

@ -3,6 +3,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Runtime.Versioning;
using osu.Desktop.LegacyIpc; using osu.Desktop.LegacyIpc;
using osu.Desktop.Windows; using osu.Desktop.Windows;
using osu.Framework; using osu.Framework;
@ -168,12 +169,26 @@ namespace osu.Desktop
private static void setupVelopack() private static void setupVelopack()
{ {
VelopackApp if (OsuGameDesktop.IsPackageManaged)
.Build() {
.WithFirstRun(v => Logger.Log("Updates are being managed by an external provider. Skipping Velopack setup.");
{ return;
if (OperatingSystem.IsWindows()) WindowsAssociationManager.InstallAssociations(); }
}).Run();
var app = VelopackApp.Build();
if (OperatingSystem.IsWindows())
configureWindows(app);
app.Run();
}
[SupportedOSPlatform("windows")]
private static void configureWindows(VelopackApp app)
{
app.WithFirstRun(_ => WindowsAssociationManager.InstallAssociations());
app.WithAfterUpdateFastCallback(_ => WindowsAssociationManager.UpdateAssociations());
app.WithBeforeUninstallFastCallback(_ => WindowsAssociationManager.UninstallAssociations());
} }
} }
} }

View File

@ -45,14 +45,17 @@ namespace osu.Desktop.Updater
private async Task<bool> checkForUpdateAsync(UpdateProgressNotification? notification = null) private async Task<bool> checkForUpdateAsync(UpdateProgressNotification? notification = null)
{ {
// should we schedule a retry on completion of this check? // whether to check again in 30 minutes. generally only if there's an error or no update was found (yet).
bool scheduleRecheck = true; bool scheduleRecheck = false;
try try
{ {
// Avoid any kind of update checking while gameplay is running. // Avoid any kind of update checking while gameplay is running.
if (localUserInfo?.IsPlaying.Value == true) if (localUserInfo?.IsPlaying.Value == true)
{
scheduleRecheck = true;
return false; return false;
}
// TODO: we should probably be checking if there's a more recent update, rather than shortcutting here. // 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). // Velopack does support this scenario (see https://github.com/ppy/osu/pull/28743#discussion_r1743495975).
@ -63,26 +66,33 @@ namespace osu.Desktop.Updater
{ {
Activated = () => Activated = () =>
{ {
restartToApplyUpdate(); Task.Run(restartToApplyUpdate);
return true; return true;
} }
}); });
return true; return true;
} }
pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false); pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false);
// Handle no updates available. // No update is available. We'll check again later.
if (pendingUpdate == null) if (pendingUpdate == null)
{
scheduleRecheck = true;
return false; return false;
}
scheduleRecheck = false; // An update is found, let's notify the user and start downloading it.
if (notification == null) if (notification == null)
{ {
notification = new UpdateProgressNotification notification = new UpdateProgressNotification
{ {
CompletionClickAction = restartToApplyUpdate, CompletionClickAction = () =>
{
Task.Run(restartToApplyUpdate);
return true;
},
}; };
Schedule(() => notificationOverlay.Post(notification)); Schedule(() => notificationOverlay.Post(notification));
@ -99,6 +109,7 @@ namespace osu.Desktop.Updater
catch (Exception e) catch (Exception e)
{ {
// In the case of an error, a separate notification will be displayed. // In the case of an error, a separate notification will be displayed.
scheduleRecheck = true;
notification.FailDownload(); notification.FailDownload();
Logger.Error(e, @"update failed!"); Logger.Error(e, @"update failed!");
} }
@ -113,7 +124,6 @@ namespace osu.Desktop.Updater
{ {
if (scheduleRecheck) if (scheduleRecheck)
{ {
// check again in 30 minutes.
Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30); Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30);
} }
} }
@ -121,13 +131,10 @@ namespace osu.Desktop.Updater
return true; return true;
} }
private bool restartToApplyUpdate() private async Task restartToApplyUpdate()
{ {
// TODO: Migrate this to async flow whenever available (see https://github.com/ppy/osu/pull/28743#discussion_r1740505665). await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);
// Currently there's an internal Thread.Sleep(300) which will cause a stutter when the user clicks to restart.
updateManager.WaitExitThenApplyUpdates(pendingUpdate?.TargetFullRelease);
Schedule(() => game.AttemptExit()); Schedule(() => game.AttemptExit());
return true;
} }
} }
} }

View File

@ -13,5 +13,7 @@ namespace osu.Desktop.Windows
private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!; 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>
@ -25,7 +26,7 @@
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<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.598-g933b2ab" /> <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

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

@ -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(),

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

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

@ -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,9 +57,9 @@ 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
return difficultyValue; return difficultyValue;
} }

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

@ -2,9 +2,12 @@
// 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.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;
@ -12,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]
@ -34,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());
@ -56,36 +124,71 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1); Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1);
} }
protected override void OnCountIncrement() private void updateCount(bool rolling)
{ {
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

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

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

@ -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(() =>
@ -218,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)
@ -230,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

@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Configuration
SetDefault(OsuRulesetSetting.ReplayFrameMarkersEnabled, false); SetDefault(OsuRulesetSetting.ReplayFrameMarkersEnabled, false);
SetDefault(OsuRulesetSetting.ReplayCursorPathEnabled, false); SetDefault(OsuRulesetSetting.ReplayCursorPathEnabled, false);
SetDefault(OsuRulesetSetting.ReplayCursorHideEnabled, false); SetDefault(OsuRulesetSetting.ReplayCursorHideEnabled, false);
SetDefault(OsuRulesetSetting.ReplayAnalysisDisplayLength, 750); SetDefault(OsuRulesetSetting.ReplayAnalysisDisplayLength, 800);
} }
} }

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

@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
if (endDragMarkerContainer != null) if (endDragMarkerContainer != null)
{ {
endDragMarkerContainer.Position = circle.Position; endDragMarkerContainer.Position = circle.Position + slider.StackOffset;
endDragMarkerContainer.Scale = CirclePiece.Scale * 1.2f; endDragMarkerContainer.Scale = CirclePiece.Scale * 1.2f;
var diff = slider.Path.PositionAt(1) - slider.Path.PositionAt(0.99f); var diff = slider.Path.PositionAt(1) - slider.Path.PositionAt(0.99f);
endDragMarkerContainer.Rotation = float.RadiansToDegrees(MathF.Atan2(diff.Y, diff.X)); endDragMarkerContainer.Rotation = float.RadiansToDegrees(MathF.Atan2(diff.Y, diff.X));

View File

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

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(),
@ -106,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

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;
var selectionQuad = OriginalSurroundingQuad.Value; IEnumerable<Vector2> points;
var tl1 = Vector2.Divide(-actualOrigin, selectionQuad.TopLeft - actualOrigin); if (axisRotation == 0)
var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.TopLeft - actualOrigin); {
var br1 = Vector2.Divide(-actualOrigin, selectionQuad.BottomRight - actualOrigin); var selectionQuad = OriginalSurroundingQuad.Value;
var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.BottomRight - actualOrigin); points = new[]
{
selectionQuad.TopLeft,
selectionQuad.TopRight,
selectionQuad.BottomLeft,
selectionQuad.BottomRight
};
}
else
points = originalConvexHull!;
if (!Precision.AlmostEquals(selectionQuad.TopLeft.X - actualOrigin.X, 0)) foreach (var point in points)
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 = clampToBound(scale, point, Vector2.Zero);
scale.Y = selectionQuad.TopLeft.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, tl2.Y, tl1.Y) : MathHelper.Clamp(scale.Y, tl1.Y, tl2.Y); scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
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,20 +91,35 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableHitObject; drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableHitObject; drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableHitObject;
} }
else
applyDim(piece);
}
void applyDim(Drawable piece) // 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.
piece.FadeColour(new Color4(195, 195, 195, 255)); applyDim(piece);
using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
piece.FadeColour(Color4.White, 100);
} }
void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho);
} }
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));
using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
piece.FadeColour(Color4.White, 100);
}
private void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho);
protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt;
private OsuInputManager osuActionInputManager; private OsuInputManager osuActionInputManager;

View File

@ -44,7 +44,9 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
if (replayPlayer != null) if (replayPlayer != null)
{ {
PlayfieldAdjustmentContainer.Add(new ReplayAnalysisOverlay(replayPlayer.Score.Replay)); 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)); replayPlayer.AddSettings(new ReplayAnalysisSettings(Config));
cursorHideEnabled = Config.GetBindable<bool>(OsuRulesetSetting.ReplayCursorHideEnabled); cursorHideEnabled = Config.GetBindable<bool>(OsuRulesetSetting.ReplayCursorHideEnabled);

View File

@ -22,6 +22,7 @@ namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis
protected override void OnApply(AnalysisFrameEntry entry) protected override void OnApply(AnalysisFrameEntry entry)
{ {
Position = entry.Position; Position = entry.Position;
Depth = -(float)entry.LifetimeEnd;
} }
} }
} }

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.Skinning.Argon;
namespace osu.Game.Rulesets.Taiko.Tests
{
[TestFixture]
public class VolumeAwareHitSampleInfoTest
{
[Test]
public void TestVolumeAwareHitSampleInfoIsNotEqualToItsUnderlyingSample(
[Values(HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP)]
string sample,
[Values(HitSampleInfo.BANK_NORMAL, HitSampleInfo.BANK_SOFT)]
string bank,
[Values(30, 70, 100)] int volume)
{
var underlyingSample = new HitSampleInfo(sample, bank, volume: volume);
var volumeAwareSample = new VolumeAwareHitSampleInfo(underlyingSample);
Assert.That(underlyingSample, Is.Not.EqualTo(volumeAwareSample));
}
}
}

View File

@ -14,19 +14,16 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.Taiko.Scoring;
namespace osu.Game.Rulesets.Taiko.Difficulty namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
public class TaikoDifficultyCalculator : DifficultyCalculator public class TaikoDifficultyCalculator : DifficultyCalculator
{ {
private const double difficulty_multiplier = 1.35; private const double difficulty_multiplier = 0.084375;
private const double rhythm_skill_multiplier = 0.2 * difficulty_multiplier;
private const double final_multiplier = 0.0625; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double rhythm_skill_multiplier = 0.2 * final_multiplier; private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.375 * final_multiplier;
private const double stamina_skill_multiplier = 0.375 * final_multiplier;
public override int Version => 20221107; public override int Version => 20221107;
@ -83,11 +80,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm); Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm);
Stamina stamina = (Stamina)skills.First(x => x is Stamina); Stamina stamina = (Stamina)skills.First(x => x is Stamina);
double colourRating = colour.DifficultyValue() * colour_skill_multiplier * difficulty_multiplier; double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier * difficulty_multiplier; double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier * difficulty_multiplier; double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
double combinedRating = combinedDifficultyValue(rhythm, colour, stamina) * difficulty_multiplier; double combinedRating = combinedDifficultyValue(rhythm, colour, stamina);
double starRating = rescale(combinedRating * 1.4); double starRating = rescale(combinedRating * 1.4);
HitWindows hitWindows = new TaikoHitWindows(); HitWindows hitWindows = new TaikoHitWindows();
@ -102,7 +99,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
ColourDifficulty = colourRating, ColourDifficulty = colourRating,
PeakDifficulty = combinedRating, PeakDifficulty = combinedRating,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit), MaxCombo = beatmap.GetMaxCombo(),
}; };
return attributes; return attributes;

View File

@ -10,7 +10,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Taiko.Edit.Blueprints namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{ {
public partial class HitPlacementBlueprint : PlacementBlueprint public partial class HitPlacementBlueprint : HitObjectPlacementBlueprint
{ {
private readonly HitPiece piece; private readonly HitPiece piece;

View File

@ -17,7 +17,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Taiko.Edit.Blueprints namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{ {
public partial class TaikoSpanPlacementBlueprint : PlacementBlueprint public partial class TaikoSpanPlacementBlueprint : HitObjectPlacementBlueprint
{ {
private readonly HitPiece headPiece; private readonly HitPiece headPiece;
private readonly HitPiece tailPiece; private readonly HitPiece tailPiece;

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Edit namespace osu.Game.Rulesets.Taiko.Edit
{ {
public class DrumRollCompositionTool : HitObjectCompositionTool public class DrumRollCompositionTool : CompositionTool
{ {
public DrumRollCompositionTool() public DrumRollCompositionTool()
: base(nameof(DrumRoll)) : base(nameof(DrumRoll))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint(); public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint();
} }
} }

View File

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

View File

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

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{ {
} }
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[] protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{ {
new HitCompositionTool(), new HitCompositionTool(),
new DrumRollCompositionTool(), new DrumRollCompositionTool(),

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.Collections.Generic; using System.Collections.Generic;
using osu.Game.Audio; using osu.Game.Audio;
@ -48,5 +49,24 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
return originalBank; return originalBank;
} }
} }
public override bool Equals(HitSampleInfo? other) => other is VolumeAwareHitSampleInfo && base.Equals(other);
/// <remarks>
/// <para>
/// This override attempts to match the <see cref="Equals"/> override above, but in theory it is not strictly necessary.
/// Recall that <see cref="GetHashCode"/> <a href="https://learn.microsoft.com/en-us/dotnet/api/system.object.gethashcode?view=net-8.0#notes-to-inheritors">must meet the following requirements</a>:
/// </para>
/// <para>
/// "If two objects compare as equal, the <see cref="GetHashCode"/> method for each object must return the same value.
/// However, if two objects do not compare as equal, <see cref="GetHashCode"/> methods for the two objects do not have to return different values."
/// </para>
/// <para>
/// Making this override combine the value generated by the base <see cref="GetHashCode"/> implementation with a constant means
/// that <see cref="HitSampleInfo"/> and <see cref="VolumeAwareHitSampleInfo"/> instances which have the same values of their members
/// will not have equal hash codes, which is slightly more efficient when these objects are used as dictionary keys.
/// </para>
/// </remarks>
public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), 1);
} }
} }

View File

@ -633,11 +633,15 @@ namespace osu.Game.Tests.NonVisual.Filtering
new object[] { "0", DateTimeOffset.Now, false }, new object[] { "0", DateTimeOffset.Now, false },
new object[] { "false", DateTimeOffset.MinValue, true }, new object[] { "false", DateTimeOffset.MinValue, true },
new object[] { "false", DateTimeOffset.Now, false }, new object[] { "false", DateTimeOffset.Now, false },
new object[] { "no", DateTimeOffset.MinValue, true },
new object[] { "no", DateTimeOffset.Now, false },
new object[] { "1", DateTimeOffset.MinValue, false }, new object[] { "1", DateTimeOffset.MinValue, false },
new object[] { "1", DateTimeOffset.Now, true }, new object[] { "1", DateTimeOffset.Now, true },
new object[] { "true", DateTimeOffset.MinValue, false }, new object[] { "true", DateTimeOffset.MinValue, false },
new object[] { "true", DateTimeOffset.Now, true }, new object[] { "true", DateTimeOffset.Now, true },
new object[] { "yes", DateTimeOffset.MinValue, false },
new object[] { "yes", DateTimeOffset.Now, true },
}; };
[Test] [Test]

View File

@ -0,0 +1,52 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Game.Utils;
namespace osu.Game.Tests.Utils
{
[TestFixture]
public class BindableValueAccessorTest
{
[Test]
public void GetValue()
{
const int value = 1337;
BindableInt bindable = new BindableInt(value);
Assert.That(BindableValueAccessor.GetValue(bindable), Is.EqualTo(value));
}
[Test]
public void SetValue()
{
const int value = 1337;
BindableInt bindable = new BindableInt();
BindableValueAccessor.SetValue(bindable, value);
Assert.That(bindable.Value, Is.EqualTo(value));
}
[Test]
public void GetInvalidBindable()
{
BindableList<object> list = new BindableList<object>();
Assert.That(BindableValueAccessor.GetValue(list), Is.EqualTo(list));
}
[Test]
public void SetInvalidBindable()
{
const int value = 1337;
BindableList<int> list = new BindableList<int> { value };
BindableValueAccessor.SetValue(list, 2);
Assert.That(list, Has.Exactly(1).Items);
Assert.That(list[0], Is.EqualTo(value));
}
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Tests.Utils
{
[TestFixture]
public class GeometryUtilsTest
{
[TestCase(new int[] { }, new int[] { })]
[TestCase(new[] { 0, 0 }, new[] { 0, 0 })]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })]
[TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, new[] { 0, 0, 4, 10, 2, -1 })]
public void TestConvexHull(int[] values, int[] expected)
{
var points = new Vector2[values.Length / 2];
for (int i = 0; i < values.Length; i += 2)
points[i / 2] = new Vector2(values[i], values[i + 1]);
var expectedPoints = new Vector2[expected.Length / 2];
for (int i = 0; i < expected.Length; i += 2)
expectedPoints[i / 2] = new Vector2(expected[i], expected[i + 1]);
var hull = GeometryUtils.GetConvexHull(points);
Assert.That(hull, Is.EquivalentTo(expectedPoints));
}
[TestCase(new int[] { }, 0, 0, 0)]
[TestCase(new[] { 0, 0 }, 0, 0, 0)]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, 1, 0, 1)]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, 1, 0, 1)]
[TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, 3, 4.5f, 5.5901699f)]
public void TestMinimumEnclosingCircle(int[] values, float x, float y, float r)
{
var points = new Vector2[values.Length / 2];
for (int i = 0; i < values.Length; i += 2)
points[i / 2] = new Vector2(values[i], values[i + 1]);
(var centre, float radius) = GeometryUtils.MinimumEnclosingCircle(points);
Assert.That(centre.X, Is.EqualTo(x).Within(0.0001));
Assert.That(centre.Y, Is.EqualTo(y).Within(0.0001));
Assert.That(radius, Is.EqualTo(r).Within(0.0001));
}
}
}

View File

@ -84,6 +84,7 @@ namespace osu.Game.Tests.Visual.Editing
targetContainer = getTargetContainer(); targetContainer = getTargetContainer();
initialRotation = targetContainer!.Rotation; initialRotation = targetContainer!.Rotation;
DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero));
base.Begin(); base.Begin();
} }
@ -134,7 +135,7 @@ namespace osu.Game.Tests.Visual.Editing
OriginalSurroundingQuad = new Quad(targetContainer!.X, targetContainer.Y, targetContainer.Width, targetContainer.Height); OriginalSurroundingQuad = new Quad(targetContainer!.X, targetContainer.Y, targetContainer.Width, targetContainer.Height);
} }
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 (targetContainer == null) if (targetContainer == null)
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!");

View File

@ -5,6 +5,7 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
@ -136,6 +137,59 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any()); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
} }
[Test]
public void TestCalibrationFromNonZeroWithImmediateReferenceScore()
{
const double average_error = -4.5;
const double initial_offset = -2;
AddStep("Set beatmap offset non-neutral", () => Realm.Write(r =>
{
r.Add(new BeatmapInfo
{
ID = Beatmap.Value.BeatmapInfo.ID,
Ruleset = Beatmap.Value.BeatmapInfo.Ruleset,
UserSettings =
{
Offset = initial_offset,
}
});
}));
AddStep("Create control with preloaded reference score", () =>
{
Child = new PlayerSettingsGroup("Some settings")
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Children = new Drawable[]
{
offsetControl = new BeatmapOffsetControl
{
ReferenceScore =
{
Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
}
}
}
}
};
});
AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Press button", () => offsetControl.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddAssert("Offset is adjusted", () => offsetControl.Current.Value, () => Is.EqualTo(initial_offset - average_error));
AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType<SettingsButton>().Single().Enabled.Value);
AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null);
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
AddStep("Clean up beatmap", () => Realm.Write(r => r.RemoveAll<BeatmapInfo>()));
}
[Test] [Test]
public void TestCalibrationNoChange() public void TestCalibrationNoChange()
{ {

View File

@ -8,7 +8,9 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
@ -26,6 +28,7 @@ using osu.Game.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Beatmaps;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
@ -177,6 +180,30 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("ensure no submission", () => Player.SubmittedScore == null); AddAssert("ensure no submission", () => Player.SubmittedScore == null);
} }
[Test]
public void TestEmptyFailStillImports()
{
prepareTestAPI(true);
createPlayerTest(true);
AddUntilStep("wait for token request", () => Player.TokenCreationRequested);
AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed);
AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value, () => Is.EqualTo(Visibility.Visible));
AddStep("attempt import", () =>
{
InputManager.MoveMouseTo(Player.ChildrenOfType<SaveFailedScoreButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("wait for import to start", () => Player.ScoreImportStarted);
AddStep("allow import", () => Player.AllowImportCompletion.Release());
AddUntilStep("import completed", () => Player.ImportedScore, () => Is.Not.Null);
AddAssert("ensure no submission", () => Player.SubmittedScore, () => Is.Null);
}
[Test] [Test]
public void TestSubmissionOnFail() public void TestSubmissionOnFail()
{ {
@ -378,6 +405,8 @@ namespace osu.Game.Tests.Visual.Gameplay
public SemaphoreSlim AllowImportCompletion { get; } public SemaphoreSlim AllowImportCompletion { get; }
public Score ImportedScore { get; private set; } public Score ImportedScore { get; private set; }
public new FailOverlay FailOverlay => base.FailOverlay;
public FakeImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) public FakeImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false)
: base(allowPause, showResults, pauseOnFocusLost) : base(allowPause, showResults, pauseOnFocusLost)
{ {

View File

@ -440,8 +440,8 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("import old classic skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"classic-layout-version-0.osk").SkinInfo); AddStep("import old classic skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"classic-layout-version-0.osk").SkinInfo);
AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType<LegacyComboCounter>().Any()); AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType<LegacyDefaultComboCounter>().Any());
AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyComboCounter>().Count() == 1); AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyDefaultComboCounter>().Count() == 1);
AddStep("add combo to global target", () => globalHUDTarget.Add(new LegacyDefaultComboCounter AddStep("add combo to global target", () => globalHUDTarget.Add(new LegacyDefaultComboCounter
{ {
@ -454,8 +454,8 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault()); AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault());
AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin); AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin);
AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded);
AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType<LegacyComboCounter>().Count() == 1); AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType<LegacyDefaultComboCounter>().Count() == 1);
AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyComboCounter>().Count() == 1); AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType<LegacyDefaultComboCounter>().Count() == 1);
} }
private Skin importSkinFromArchives(string filename) private Skin importSkinFromArchives(string filename)

View File

@ -19,6 +19,7 @@ using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens; using osu.Game.Screens;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD.JudgementCounter;
using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Gameplay; using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Tests.Visual.Multiplayer;
@ -167,14 +168,16 @@ namespace osu.Game.Tests.Visual.Gameplay
public void TestSpectatingDuringGameplay() public void TestSpectatingDuringGameplay()
{ {
start(); start();
sendFrames(300); sendFrames(300, initialResultCount: 100);
loadSpectatingScreen(); loadSpectatingScreen();
waitForPlayerCurrent(); waitForPlayerCurrent();
sendFrames(300); sendFrames(300, initialResultCount: 100);
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000)); AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000));
AddAssert("check judgement counts are correct", () => player.ChildrenOfType<JudgementCountController>().Single().Counters.Sum(c => c.ResultCount.Value),
() => Is.GreaterThanOrEqualTo(100));
} }
[Test] [Test]
@ -405,9 +408,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void checkPaused(bool state) => private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state); AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
private void sendFrames(int count = 10, double startTime = 0) private void sendFrames(int count = 10, double startTime = 0, int initialResultCount = 0)
{ {
AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime)); AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime, initialResultCount));
} }
private void loadSpectatingScreen() private void loadSpectatingScreen()

View File

@ -144,6 +144,28 @@ namespace osu.Game.Tests.Visual.Navigation
exitViaEscapeAndConfirm(); exitViaEscapeAndConfirm();
} }
[Test]
public void TestEnterGameplayWhileFilteringToNoSelection()
{
TestPlaySongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("force selection", () =>
{
songSelect.FinaliseSelection();
songSelect.FilterControl.CurrentTextSearch.Value = "test";
});
AddUntilStep("wait for player", () => !songSelect.IsCurrentScreen());
AddStep("return to song select", () => songSelect.MakeCurrent());
AddUntilStep("wait for selection lost", () => songSelect.Beatmap.IsDefault);
}
[Test] [Test]
public void TestSongSelectBackActionHandling() public void TestSongSelectBackActionHandling()
{ {

View File

@ -66,8 +66,8 @@ namespace osu.Game.Tests.Visual.Online
[Test] [Test]
public void TestPlayCountRankingTier() public void TestPlayCountRankingTier()
{ {
AddAssert("1 before silver", () => DailyChallengeStatsDisplay.TierForPlayCount(30) == RankingTier.Bronze); AddAssert("1 before silver", () => DailyChallengeStatsTooltip.TierForPlayCount(30) == RankingTier.Bronze);
AddAssert("first silver", () => DailyChallengeStatsDisplay.TierForPlayCount(31) == RankingTier.Silver); AddAssert("first silver", () => DailyChallengeStatsTooltip.TierForPlayCount(31) == RankingTier.Silver);
} }
} }
} }

View File

@ -5,14 +5,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online; using osu.Game.Online;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
@ -23,6 +27,7 @@ using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Ranking.Statistics.User;
using osu.Game.Tests.Resources; using osu.Game.Tests.Resources;
using osu.Game.Users; using osu.Game.Users;
using osuTK; using osuTK;
@ -80,6 +85,69 @@ namespace osu.Game.Tests.Visual.Ranking
loadPanel(null); loadPanel(null);
} }
[Test]
public void TestStatisticsShownCorrectlyIfUpdateDeliveredBeforeLoad()
{
UserStatisticsWatcher userStatisticsWatcher = null!;
ScoreInfo score = null!;
AddStep("create user statistics watcher", () => Add(userStatisticsWatcher = new UserStatisticsWatcher()));
AddStep("set user statistics update", () =>
{
score = TestResources.CreateTestScoreInfo();
score.OnlineID = 1234;
((Bindable<UserStatisticsUpdate>)userStatisticsWatcher.LatestUpdate).Value = new UserStatisticsUpdate(score,
new UserStatistics
{
Level = new UserStatistics.LevelInfo
{
Current = 5,
Progress = 20,
},
GlobalRank = 38000,
CountryRank = 12006,
PP = 2134,
RankedScore = 21123849,
Accuracy = 0.985,
PlayCount = 13375,
PlayTime = 354490,
TotalScore = 128749597,
TotalHits = 0,
MaxCombo = 1233,
}, new UserStatistics
{
Level = new UserStatistics.LevelInfo
{
Current = 5,
Progress = 30,
},
GlobalRank = 36000,
CountryRank = 12000,
PP = (decimal)2134.5,
RankedScore = 23897015,
Accuracy = 0.984,
PlayCount = 13376,
PlayTime = 35789,
TotalScore = 132218497,
TotalHits = 0,
MaxCombo = 1233,
});
});
AddStep("load user statistics panel", () => Child = new DependencyProvidingContainer
{
CachedDependencies = [(typeof(UserStatisticsWatcher), userStatisticsWatcher)],
RelativeSizeAxes = Axes.Both,
Child = new UserStatisticsPanel(score)
{
RelativeSizeAxes = Axes.Both,
State = { Value = Visibility.Visible },
Score = { Value = score, }
}
});
AddUntilStep("overall ranking present", () => this.ChildrenOfType<OverallRanking>().Any());
AddUntilStep("loading spinner not visible", () => this.ChildrenOfType<LoadingLayer>().All(l => l.State.Value == Visibility.Hidden));
}
private void loadPanel(ScoreInfo score) => AddStep("load panel", () => private void loadPanel(ScoreInfo score) => AddStep("load panel", () =>
{ {
Child = new UserStatisticsPanel(score) Child = new UserStatisticsPanel(score)

View File

@ -9,6 +9,11 @@ namespace osu.Game.Tests.Visual.Settings
{ {
public partial class TestSceneDirectorySelector : ThemeComparisonTestScene public partial class TestSceneDirectorySelector : ThemeComparisonTestScene
{ {
public TestSceneDirectorySelector()
: base(false)
{
}
protected override Drawable CreateContent() => new OsuDirectorySelector protected override Drawable CreateContent() => new OsuDirectorySelector
{ {
RelativeSizeAxes = Axes.Both RelativeSizeAxes = Axes.Both

View File

@ -1,37 +1,49 @@
// 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 NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Tests.Visual.UserInterface; using osu.Game.Tests.Visual.UserInterface;
namespace osu.Game.Tests.Visual.Settings namespace osu.Game.Tests.Visual.Settings
{ {
public partial class TestSceneFileSelector : ThemeComparisonTestScene public partial class TestSceneFileSelector : ThemeComparisonTestScene
{ {
[Resolved] public TestSceneFileSelector()
private OsuColour colours { get; set; } = null!; : base(false)
{
}
[Test] [Test]
public void TestJpgFilesOnly() public void TestJpgFilesOnly()
{ {
AddStep("create", () => AddStep("create", () =>
{ {
ContentContainer.Children = new Drawable[] var colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
ContentContainer.Child = new DependencyProvidingContainer
{ {
new Box RelativeSizeAxes = Axes.Both,
CachedDependencies = new (Type, object)[]
{ {
RelativeSizeAxes = Axes.Both, (typeof(OverlayColourProvider), colourProvider)
Colour = colours.GreySeaFoam
}, },
new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) Children = new Drawable[]
{ {
RelativeSizeAxes = Axes.Both, new Box
}, {
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background3
},
new OsuFileSelector(validFileExtensions: new[] { ".jpg" })
{
RelativeSizeAxes = Axes.Both,
},
}
}; };
}); });
} }

View File

@ -5,6 +5,7 @@ using NUnit.Framework;
using osu.Framework.Bindables; using osu.Framework.Bindables;
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.Localisation; using osu.Framework.Localisation;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
@ -19,16 +20,20 @@ namespace osu.Game.Tests.Visual.Settings
{ {
Children = new Drawable[] Children = new Drawable[]
{ {
new FillFlowContainer new PopoverContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical, Child = new FillFlowContainer
Spacing = new Vector2(20), {
Width = 0.5f, RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre, Direction = FillDirection.Vertical,
Origin = Anchor.Centre, Spacing = new Vector2(20),
Padding = new MarginPadding(50), Width = 0.5f,
ChildrenEnumerable = new TestTargetClass().CreateSettingsControls() Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Padding = new MarginPadding(50),
ChildrenEnumerable = new TestTargetClass().CreateSettingsControls()
},
}, },
}; };
} }
@ -66,6 +71,13 @@ namespace osu.Game.Tests.Visual.Settings
[SettingSource("Sample number textbox", "Textbox number entry", SettingControlType = typeof(SettingsNumberBox))] [SettingSource("Sample number textbox", "Textbox number entry", SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> IntTextBoxBindable { get; } = new Bindable<int?>(); public Bindable<int?> IntTextBoxBindable { get; } = new Bindable<int?>();
[SettingSource("Sample colour", "Change the colour", SettingControlType = typeof(SettingsColour))]
public BindableColour4 ColourBindable { get; } = new BindableColour4
{
Default = Colour4.White,
Value = Colour4.Red
};
} }
private enum TestEnum private enum TestEnum

View File

@ -520,6 +520,17 @@ namespace osu.Game.Tests.Visual.SongSelect
waitForSelection(set_count); waitForSelection(set_count);
} }
[Test]
public void TestDifficultiesSplitOutOnLoad()
{
loadBeatmaps(new List<BeatmapSetInfo> { TestResources.CreateTestBeatmapSetInfo(diff_count) }, () => new FilterCriteria
{
Sort = SortMode.Difficulty,
});
checkVisibleItemCount(false, 3);
}
[Test] [Test]
public void TestAddRemoveDifficultySort() public void TestAddRemoveDifficultySort()
{ {
@ -1120,6 +1131,32 @@ namespace osu.Game.Tests.Visual.SongSelect
AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1);
} }
[Test]
public void TestCarouselRetainsSelectionFromDifficultySort()
{
List<BeatmapSetInfo> manySets = new List<BeatmapSetInfo>();
AddStep("Populate beatmap sets", () =>
{
manySets.Clear();
for (int i = 1; i <= 50; i++)
manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count));
});
loadBeatmaps(manySets);
BeatmapInfo chosenBeatmap = null!;
AddStep("select given beatmap", () => carousel.SelectBeatmap(chosenBeatmap = manySets[20].Beatmaps[0]));
AddUntilStep("selection changed", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap));
AddStep("sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty }));
AddAssert("selection retained", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap));
AddStep("sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title }));
AddAssert("selection retained", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap));
}
[Test] [Test]
public void TestFilteringByUserStarDifficulty() public void TestFilteringByUserStarDifficulty()
{ {

View File

@ -1,11 +1,14 @@
// 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 osu.Framework.Bindables;
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;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osuTK; using osuTK;
namespace osu.Game.Tests.Visual.UserInterface namespace osu.Game.Tests.Visual.UserInterface
@ -25,7 +28,10 @@ namespace osu.Game.Tests.Visual.UserInterface
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer Child = new FillFlowContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 400,
Direction = FillDirection.Vertical, Direction = FillDirection.Vertical,
Spacing = new Vector2(5), Spacing = new Vector2(5),
Padding = new MarginPadding(10), Padding = new MarginPadding(10),
@ -53,9 +59,55 @@ namespace osu.Game.Tests.Visual.UserInterface
PlaceholderText = "Mine is 42!", PlaceholderText = "Mine is 42!",
TabbableContentContainer = this, TabbableContentContainer = this,
}, },
new FormCheckBox
{
Caption = EditorSetupStrings.LetterboxDuringBreaks,
HintText = EditorSetupStrings.LetterboxDuringBreaksDescription,
},
new FormCheckBox
{
Caption = EditorSetupStrings.LetterboxDuringBreaks,
HintText = EditorSetupStrings.LetterboxDuringBreaksDescription,
Current = { Disabled = true },
},
new FormSliderBar<float>
{
Caption = "Instantaneous slider",
Current = new BindableFloat
{
MinValue = 0,
MaxValue = 10,
Value = 5,
Precision = 0.1f,
},
TabbableContentContainer = this,
},
new FormSliderBar<float>
{
Caption = "Non-instantaneous slider",
Current = new BindableFloat
{
MinValue = 0,
MaxValue = 10,
Value = 5,
Precision = 0.1f,
},
Instantaneous = false,
TabbableContentContainer = this,
},
new FormEnumDropdown<CountdownType>
{
Caption = EditorSetupStrings.EnableCountdown,
HintText = EditorSetupStrings.CountdownDescription,
},
new FormFileSelector
{
Caption = "Audio file",
PlaceholderText = "Select an audio file",
},
}, },
}, },
}, }
}; };
} }
} }

View File

@ -0,0 +1,75 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays.Settings;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Tests.Visual.UserInterface
{
public partial class TestSceneSettingsColour : OsuManualInputManagerTestScene
{
private SettingsColour? component;
[Test]
public void TestColour()
{
createContent();
AddRepeatStep("set random colour", () => component!.Current.Value = randomColour(), 4);
}
[Test]
public void TestUserInteractions()
{
createContent();
AddStep("click colour", () =>
{
InputManager.MoveMouseTo(component!);
InputManager.Click(MouseButton.Left);
});
AddAssert("colour picker spawned", () => this.ChildrenOfType<OsuColourPicker>().Any());
}
private void createContent()
{
AddStep("create component", () =>
{
Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 500,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
component = new SettingsColour
{
LabelText = "a sample component",
},
},
},
};
});
}
private Colour4 randomColour() => new Color4(
RNG.NextSingle(),
RNG.NextSingle(),
RNG.NextSingle(),
1);
}
}

View File

@ -27,6 +27,9 @@ namespace osu.Game.Tournament.Screens.Setup
[Resolved] [Resolved]
private MatchIPCInfo ipc { get; set; } = null!; private MatchIPCInfo ipc { get; set; } = null!;
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private OsuDirectorySelector directorySelector = null!; private OsuDirectorySelector directorySelector = null!;
private DialogOverlay? overlay; private DialogOverlay? overlay;

View File

@ -96,7 +96,7 @@ namespace osu.Game.Audio
public virtual HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default) public virtual HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default)
=> new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume));
public bool Equals(HitSampleInfo? other) public virtual bool Equals(HitSampleInfo? other)
=> other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix; => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix;
public override bool Equals(object? obj) public override bool Equals(object? obj)

View File

@ -198,8 +198,11 @@ namespace osu.Game.Beatmaps
if (beatmapSet.OnlineID > 0) if (beatmapSet.OnlineID > 0)
{ {
// Required local for iOS. Will cause runtime crash if inlined.
int onlineId = beatmapSet.OnlineID;
// OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure. // OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure.
foreach (var existingSetWithSameOnlineID in realm.All<BeatmapSetInfo>().Where(b => b.OnlineID == beatmapSet.OnlineID)) foreach (var existingSetWithSameOnlineID in realm.All<BeatmapSetInfo>().Where(b => b.OnlineID == onlineId))
{ {
existingSetWithSameOnlineID.DeletePending = true; existingSetWithSameOnlineID.DeletePending = true;
existingSetWithSameOnlineID.OnlineID = -1; existingSetWithSameOnlineID.OnlineID = -1;

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
@ -78,7 +79,7 @@ namespace osu.Game.Beatmaps
// cached database exists on disk. // cached database exists on disk.
&& storage.Exists(cache_database_name); && storage.Exists(cache_database_name);
public bool TryLookup(BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) public bool TryLookup(BeatmapInfo beatmapInfo, [NotNullWhen(true)] out OnlineBeatmapMetadata? onlineMetadata)
{ {
Debug.Assert(beatmapInfo.BeatmapSet != null); Debug.Assert(beatmapInfo.BeatmapSet != null);
@ -98,7 +99,7 @@ namespace osu.Game.Beatmaps
try try
{ {
using (var db = new SqliteConnection(string.Concat(@"Data Source=", storage.GetFullPath(@"online.db", true)))) using (var db = getConnection())
{ {
db.Open(); db.Open();
@ -125,6 +126,9 @@ namespace osu.Game.Beatmaps
return false; return false;
} }
private SqliteConnection getConnection() =>
new SqliteConnection(string.Concat(@"Data Source=", storage.GetFullPath(@"online.db", true)));
private void prepareLocalCache() private void prepareLocalCache()
{ {
bool isRefetch = storage.Exists(cache_database_name); bool isRefetch = storage.Exists(cache_database_name);
@ -191,6 +195,15 @@ namespace osu.Game.Beatmaps
}); });
} }
public int GetCacheVersion()
{
using (var connection = getConnection())
{
connection.Open();
return getCacheVersion(connection);
}
}
private int getCacheVersion(SqliteConnection connection) private int getCacheVersion(SqliteConnection connection)
{ {
using (var cmd = connection.CreateCommand()) using (var cmd = connection.CreateCommand())

View File

@ -4,7 +4,6 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using JetBrains.Annotations; using JetBrains.Annotations;
@ -15,6 +14,7 @@ using osu.Framework.Graphics;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
using osu.Game.Utils;
namespace osu.Game.Configuration namespace osu.Game.Configuration
{ {
@ -186,6 +186,16 @@ namespace osu.Game.Configuration
break; break;
case BindableColour4 bColour:
yield return new SettingsColour
{
LabelText = attr.Label,
TooltipText = attr.Description,
Current = bColour
};
break;
case IBindable bindable: case IBindable bindable:
var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]);
var dropdown = (Drawable)Activator.CreateInstance(dropdownType)!; var dropdown = (Drawable)Activator.CreateInstance(dropdownType)!;
@ -227,11 +237,11 @@ namespace osu.Game.Configuration
case Bindable<bool> b: case Bindable<bool> b:
return b.Value; return b.Value;
case BindableColour4 c:
return c.Value.ToHex();
case IBindable u: case IBindable u:
// An unknown (e.g. enum) generic type. return BindableValueAccessor.GetValue(u);
var valueMethod = u.GetType().GetProperty(nameof(IBindable<int>.Value));
Debug.Assert(valueMethod != null);
return valueMethod.GetValue(u)!;
default: default:
// fall back for non-bindable cases. // fall back for non-bindable cases.

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -11,6 +12,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -61,6 +63,9 @@ namespace osu.Game.Database
[Resolved] [Resolved]
private IAPIProvider api { get; set; } = null!; private IAPIProvider api { get; set; } = null!;
[Resolved]
private Storage storage { get; set; } = null!;
protected virtual int TimeToSleepDuringGameplay => 30000; protected virtual int TimeToSleepDuringGameplay => 30000;
protected override void LoadComplete() protected override void LoadComplete()
@ -78,6 +83,7 @@ namespace osu.Game.Database
processScoresWithMissingStatistics(); processScoresWithMissingStatistics();
convertLegacyTotalScoreToStandardised(); convertLegacyTotalScoreToStandardised();
upgradeScoreRanks(); upgradeScoreRanks();
backpopulateMissingSubmissionAndRankDates();
}, TaskCreationOptions.LongRunning).ContinueWith(t => }, TaskCreationOptions.LongRunning).ContinueWith(t =>
{ {
if (t.Exception?.InnerException is ObjectDisposedException) if (t.Exception?.InnerException is ObjectDisposedException)
@ -443,6 +449,104 @@ namespace osu.Game.Database
completeNotification(notification, processedCount, scoreIds.Count, failedCount); completeNotification(notification, processedCount, scoreIds.Count, failedCount);
} }
private void backpopulateMissingSubmissionAndRankDates()
{
var localMetadataSource = new LocalCachedBeatmapMetadataSource(storage);
if (!localMetadataSource.Available)
{
Logger.Log("Cannot backpopulate missing submission/rank dates because the local metadata cache is missing.");
return;
}
try
{
if (localMetadataSource.GetCacheVersion() < 2)
{
Logger.Log("Cannot backpopulate missing submission/rank dates because the local metadata cache is too old.");
return;
}
}
catch (Exception ex)
{
Logger.Log($"Error when trying to query version of local metadata cache: {ex}");
return;
}
Logger.Log("Querying for beatmap sets that contain missing submission/rank date...");
HashSet<Guid> beatmapSetIds = realmAccess.Run(r => new HashSet<Guid>(
r.All<BeatmapSetInfo>()
.Where(b => b.StatusInt > 0 && (b.DateRanked == null || b.DateSubmitted == null))
.AsEnumerable()
.Select(b => b.ID)));
if (beatmapSetIds.Count == 0)
return;
Logger.Log($"Found {beatmapSetIds.Count} beatmap sets with missing submission/rank date.");
var notification = showProgressNotification(beatmapSetIds.Count, "Populating missing submission and rank dates", "beatmap sets now have correct submission and rank dates.");
int processedCount = 0;
int failedCount = 0;
foreach (var id in beatmapSetIds)
{
if (notification?.State == ProgressNotificationState.Cancelled)
break;
updateNotificationProgress(notification, processedCount, beatmapSetIds.Count);
sleepIfRequired();
try
{
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
bool succeeded = realmAccess.Write(r =>
{
BeatmapSetInfo beatmapSet = r.Find<BeatmapSetInfo>(id)!;
// we want any ranked representative of the set.
// the reason for checking ranked status of the difficulty is that it can be locally modified,
// at which point the lookup will fail - but there might still be another unmodified difficulty on which it will work.
if (beatmapSet.Beatmaps.FirstOrDefault(b => b.Status >= BeatmapOnlineStatus.Ranked) is not BeatmapInfo beatmap)
return false;
bool lookupSucceeded = localMetadataSource.TryLookup(beatmap, out var result);
if (lookupSucceeded)
{
Debug.Assert(result != null);
beatmapSet.DateRanked = result.DateRanked;
beatmapSet.DateSubmitted = result.DateSubmitted;
return true;
}
Logger.Log($"Could not find {beatmapSet.GetDisplayString()} in local cache while backpopulating missing submission/rank date");
return false;
});
if (succeeded)
++processedCount;
else
++failedCount;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception e)
{
Logger.Log($"Failed to update ranked/submitted dates for beatmap set {id}: {e}");
++failedCount;
}
}
completeNotification(notification, processedCount, beatmapSetIds.Count, failedCount);
}
private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount) private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount)
{ {
if (notification == null) if (notification == null)

View File

@ -195,6 +195,7 @@ namespace osu.Game.Database
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
// Consider using hard links here to make this instant.
using (var inStream = Files.Storage.GetStream(sourcePath)) using (var inStream = Files.Storage.GetStream(sourcePath))
using (var outStream = File.Create(destinationPath)) using (var outStream = File.Create(destinationPath))
await inStream.CopyToAsync(outStream).ConfigureAwait(false); await inStream.CopyToAsync(outStream).ConfigureAwait(false);

View File

@ -75,7 +75,7 @@ namespace osu.Game.Graphics.UserInterface
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(OverlayColourProvider? colourProvider, OsuColour colours, AudioManager audio) private void load(OverlayColourProvider? colourProvider, OsuColour colours, AudioManager audio)
{ {
BackgroundColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); BackgroundColour = colourProvider?.Background5 ?? Color4.Black;
HoverColour = colourProvider?.Light4 ?? colours.PinkDarker; HoverColour = colourProvider?.Light4 ?? colours.PinkDarker;
SelectionColour = colourProvider?.Background3 ?? colours.PinkDarker.Opacity(0.5f); SelectionColour = colourProvider?.Background3 ?? colours.PinkDarker.Opacity(0.5f);
@ -397,7 +397,7 @@ namespace osu.Game.Graphics.UserInterface
{ {
bool hovered = Enabled.Value && IsHovered; bool hovered = Enabled.Value && IsHovered;
var hoveredColour = colourProvider?.Light4 ?? colours.PinkDarker; var hoveredColour = colourProvider?.Light4 ?? colours.PinkDarker;
var unhoveredColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); var unhoveredColour = colourProvider?.Background5 ?? Color4.Black;
Colour = Color4.White; Colour = Color4.White;
Alpha = Enabled.Value ? 1 : 0.3f; Alpha = Enabled.Value ? 1 : 0.3f;

View File

@ -46,7 +46,7 @@ namespace osu.Game.Graphics.UserInterface
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
CurrentNumber.BindValueChanged(current => TooltipText = getTooltipText(current.NewValue), true); CurrentNumber.BindValueChanged(current => TooltipText = GetDisplayableValue(current.NewValue), true);
} }
protected override void OnUserChange(T value) protected override void OnUserChange(T value)
@ -55,7 +55,7 @@ namespace osu.Game.Graphics.UserInterface
playSample(value); playSample(value);
TooltipText = getTooltipText(value); TooltipText = GetDisplayableValue(value);
} }
private void playSample(T value) private void playSample(T value)
@ -83,7 +83,7 @@ namespace osu.Game.Graphics.UserInterface
channel.Play(); channel.Play();
} }
private LocalisableString getTooltipText(T value) public LocalisableString GetDisplayableValue(T value)
{ {
if (CurrentNumber.IsInteger) if (CurrentNumber.IsInteger)
return int.CreateTruncating(value).ToString("N0"); return int.CreateTruncating(value).ToString("N0");

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