1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-12 15:52:55 +08:00

Merge branch 'master' into cognition

This commit is contained in:
Givikap120 2024-10-18 00:53:38 +03:00
commit f8e2874afd
80 changed files with 1443 additions and 338 deletions

View File

@ -273,22 +273,23 @@ jobs:
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
@ -304,22 +305,23 @@ jobs:
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
@ -339,9 +341,12 @@ jobs:
sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose up --build generator
link=$(docker-compose logs generator -n 10 | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
docker compose up --build --detach
docker compose logs --follow &
docker compose wait generator
link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
echo "TARGET=${target}" >> "${GITHUB_OUTPUT}"
@ -351,7 +356,7 @@ jobs:
if: ${{ always() }}
run: |
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose down -v
docker compose down --volumes
output-cli:
name: Output info

View File

@ -1,5 +1,6 @@
{
"recommendations": [
"ms-dotnettools.csharp"
"editorconfig.editorconfig",
"ms-dotnettools.csdevkit"
]
}

View File

@ -53,7 +53,7 @@ Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed.
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed.
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed.
### Downloading the source code

View File

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

View File

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

View File

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

View File

@ -25,6 +25,8 @@ namespace osu.Desktop.Updater
[Resolved]
private ILocalUserPlayInfo? localUserInfo { get; set; }
private bool isInGameplay => localUserInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying;
private UpdateInfo? pendingUpdate;
public VelopackUpdateManager()
@ -43,7 +45,7 @@ namespace osu.Desktop.Updater
protected override async Task<bool> PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false);
private async Task<bool> checkForUpdateAsync(UpdateProgressNotification? notification = null)
private async Task<bool> checkForUpdateAsync()
{
// whether to check again in 30 minutes. generally only if there's an error or no update was found (yet).
bool scheduleRecheck = false;
@ -51,10 +53,10 @@ namespace osu.Desktop.Updater
try
{
// Avoid any kind of update checking while gameplay is running.
if (localUserInfo?.IsPlaying.Value == true)
if (isInGameplay)
{
scheduleRecheck = true;
return false;
return true;
}
// TODO: we should probably be checking if there's a more recent update, rather than shortcutting here.
@ -84,27 +86,22 @@ namespace osu.Desktop.Updater
}
// An update is found, let's notify the user and start downloading it.
if (notification == null)
UpdateProgressNotification notification = new UpdateProgressNotification
{
notification = new UpdateProgressNotification
CompletionClickAction = () =>
{
CompletionClickAction = () =>
{
Task.Run(restartToApplyUpdate);
return true;
},
};
Schedule(() => notificationOverlay.Post(notification));
}
Task.Run(restartToApplyUpdate);
return true;
},
};
runOutsideOfGameplay(() => notificationOverlay.Post(notification));
notification.StartDownload();
try
{
await updateManager.DownloadUpdatesAsync(pendingUpdate, p => notification.Progress = p / 100f).ConfigureAwait(false);
notification.State = ProgressNotificationState.Completed;
runOutsideOfGameplay(() => notification.State = ProgressNotificationState.Completed);
}
catch (Exception e)
{
@ -131,6 +128,17 @@ namespace osu.Desktop.Updater
return true;
}
private void runOutsideOfGameplay(Action action)
{
if (isInGameplay)
{
Scheduler.AddDelayed(() => runOutsideOfGameplay(action), 1000);
return;
}
action();
}
private async Task restartToApplyUpdate()
{
await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);

View File

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

View File

@ -24,7 +24,7 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="System.IO.Packaging" Version="8.0.0" />
<PackageReference Include="System.IO.Packaging" Version="8.0.1" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.630-g9c52e40" />
</ItemGroup>

View File

@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
private readonly bool isForCurrentRuleset;
private readonly double originalOverallDifficulty;
public override int Version => 20230817;
public override int Version => 20241007;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)

View File

@ -9,6 +9,7 @@ using osu.Framework.Utils;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Visual;
using osu.Game.Utils;
@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void gridActive<T>(bool active) where T : PositionSnapGrid
{
AddAssert($"grid type is {typeof(T).Name}", () => this.ChildrenOfType<T>().Any());
AddStep("choose placement tool", () => InputManager.Key(Key.Number2));
AddStep("move cursor to spacing + (1, 1)", () =>
{
@ -161,7 +163,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return grid switch
{
RectangularPositionSnapGrid rectangular => rectangular.StartPosition.Value + GeometryUtils.RotateVector(rectangular.Spacing.Value, -rectangular.GridLineRotation.Value),
TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value),
TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector(
new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value),
CircularPositionSnapGrid circular => circular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(circular.Spacing.Value, 0), -45),
_ => Vector2.Zero
};
@ -170,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test]
public void TestGridSizeToggling()
{
AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
AddStep("enable rectangular grid", () => InputManager.Key(Key.Y));
AddUntilStep("rectangular grid visible", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Any());
gridSizeIs(4);
@ -189,5 +192,97 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void gridSizeIs(int size)
=> AddAssert($"grid size is {size}", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Single().Spacing.Value == new Vector2(size)
&& EditorBeatmap.BeatmapInfo.GridSize == size);
[Test]
public void TestGridTypeToggling()
{
AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
AddUntilStep("rectangular grid visible", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Any());
gridActive<RectangularPositionSnapGrid>(true);
nextGridTypeIs<TriangularPositionSnapGrid>();
nextGridTypeIs<CircularPositionSnapGrid>();
nextGridTypeIs<RectangularPositionSnapGrid>();
}
private void nextGridTypeIs<T>() where T : PositionSnapGrid
{
AddStep("toggle to next grid type", () =>
{
InputManager.PressKey(Key.ShiftLeft);
InputManager.Key(Key.G);
InputManager.ReleaseKey(Key.ShiftLeft);
});
gridActive<T>(true);
}
[Test]
public void TestGridPlacementTool()
{
AddStep("enable rectangular grid", () => InputManager.Key(Key.T));
AddStep("start grid placement", () => InputManager.Key(Key.Number5));
AddStep("move cursor to slider head + (1, 1)", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).Position + new Vector2(1, 1)));
});
AddStep("left click", () => InputManager.Click(MouseButton.Left));
AddStep("move cursor to slider tail + (1, 1)", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1)));
});
AddStep("left click", () => InputManager.Click(MouseButton.Left));
gridActive<RectangularPositionSnapGrid>(true);
AddAssert("grid position at slider head", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).Position, composer.StartPosition.Value);
});
AddAssert("grid spacing is distance to slider tail", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01)
&& Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y);
});
AddAssert("grid rotation points to slider tail", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
return Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01);
});
AddStep("start grid placement", () => InputManager.Key(Key.Number5));
AddStep("move cursor to slider tail + (1, 1)", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1)));
});
AddStep("double click", () =>
{
InputManager.Click(MouseButton.Left);
InputManager.Click(MouseButton.Left);
});
AddStep("move cursor to (0, 0)", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
InputManager.MoveMouseTo(composer.ToScreenSpace(Vector2.Zero));
});
gridActive<RectangularPositionSnapGrid>(true);
AddAssert("grid position at slider tail", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).EndPosition, composer.StartPosition.Value);
});
AddAssert("grid spacing and rotation unchanged", () =>
{
var composer = Editor.ChildrenOfType<RectangularPositionSnapGrid>().Single();
return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01)
&& Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y)
&& Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01);
});
}
}
}

View File

@ -0,0 +1,64 @@
// 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.Testing;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.UI;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Mods
{
public partial class TestSceneOsuModMirror : OsuModTestScene
{
[Test]
public void TestCorrectReflections([Values] OsuModMirror.MirrorType type, [Values] bool withStrictTracking) => CreateModTest(new ModTestData
{
Autoplay = true,
Beatmap = new OsuBeatmap
{
HitObjects =
{
new Slider
{
Position = new Vector2(0),
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(),
new PathControlPoint(new Vector2(100, 0))
}
},
TickDistanceMultiplier = 0.5,
RepeatCount = 1,
}
}
},
Mods = withStrictTracking
? [new OsuModMirror { Reflection = { Value = type } }, new OsuModStrictTracking()]
: [new OsuModMirror { Reflection = { Value = type } }],
PassCondition = () =>
{
var slider = this.ChildrenOfType<DrawableSlider>().SingleOrDefault();
var playfield = this.ChildrenOfType<OsuPlayfield>().Single();
if (slider == null)
return false;
return Precision.AlmostEquals(playfield.ToLocalSpace(slider.HeadCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position)
&& Precision.AlmostEquals(playfield.ToLocalSpace(slider.TailCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position)
&& Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType<DrawableSliderRepeat>().Single().ScreenSpaceDrawQuad.Centre),
slider.HitObject.Position + slider.HitObject.Path.PositionAt(1))
&& Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType<DrawableSliderTick>().First().ScreenSpaceDrawQuad.Centre),
slider.HitObject.Position + slider.HitObject.Path.PositionAt(0.7f));
}
});
}
}

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public const double DIFFICULTY_MULTIPLIER = 0.0668;
public const double SUM_POWER = 1.1;
public const double FL_SUM_POWER = 1.5;
public override int Version => 20220902;
public override int Version => 20241007;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)

View File

@ -0,0 +1,126 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Edit;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints
{
public partial class GridPlacementBlueprint : PlacementBlueprint
{
[Resolved]
private HitObjectComposer? hitObjectComposer { get; set; }
private OsuGridToolboxGroup gridToolboxGroup = null!;
private Vector2 originalOrigin;
private float originalSpacing;
private float originalRotation;
[BackgroundDependencyLoader]
private void load(OsuGridToolboxGroup gridToolboxGroup)
{
this.gridToolboxGroup = gridToolboxGroup;
originalOrigin = gridToolboxGroup.StartPosition.Value;
originalSpacing = gridToolboxGroup.Spacing.Value;
originalRotation = gridToolboxGroup.GridLinesRotation.Value;
}
public override void EndPlacement(bool commit)
{
if (!commit && PlacementActive != PlacementState.Finished)
{
gridToolboxGroup.StartPosition.Value = originalOrigin;
gridToolboxGroup.Spacing.Value = originalSpacing;
if (!gridToolboxGroup.GridLinesRotation.Disabled)
gridToolboxGroup.GridLinesRotation.Value = originalRotation;
}
base.EndPlacement(commit);
// You typically only place the grid once, so we switch back to the last tool after placement.
if (commit && hitObjectComposer is OsuHitObjectComposer osuHitObjectComposer)
osuHitObjectComposer.SetLastTool();
}
protected override bool OnClick(ClickEvent e)
{
if (e.Button == MouseButton.Left)
{
switch (PlacementActive)
{
case PlacementState.Waiting:
BeginPlacement(true);
return true;
case PlacementState.Active:
EndPlacement(true);
return true;
}
}
return base.OnClick(e);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (e.Button == MouseButton.Right)
{
// Reset the grid to the default values.
gridToolboxGroup.StartPosition.Value = gridToolboxGroup.StartPosition.Default;
gridToolboxGroup.Spacing.Value = gridToolboxGroup.Spacing.Default;
if (!gridToolboxGroup.GridLinesRotation.Disabled)
gridToolboxGroup.GridLinesRotation.Value = gridToolboxGroup.GridLinesRotation.Default;
EndPlacement(true);
return true;
}
return base.OnMouseDown(e);
}
protected override bool OnDragStart(DragStartEvent e)
{
if (e.Button == MouseButton.Left)
{
BeginPlacement(true);
return true;
}
return base.OnDragStart(e);
}
protected override void OnDragEnd(DragEndEvent e)
{
if (PlacementActive == PlacementState.Active)
EndPlacement(true);
base.OnDragEnd(e);
}
public override SnapType SnapType => ~SnapType.GlobalGrids;
public override void UpdateTimeAndPosition(SnapResult result)
{
var pos = ToLocalSpace(result.ScreenSpacePosition);
if (PlacementActive != PlacementState.Active)
gridToolboxGroup.StartPosition.Value = pos;
else
{
// Default to the original spacing and rotation if the distance is too small.
if (Vector2.Distance(gridToolboxGroup.StartPosition.Value, pos) < 2)
{
gridToolboxGroup.Spacing.Value = originalSpacing;
if (!gridToolboxGroup.GridLinesRotation.Disabled)
gridToolboxGroup.GridLinesRotation.Value = originalRotation;
}
else
{
gridToolboxGroup.SetGridFromPoints(gridToolboxGroup.StartPosition.Value, pos);
}
}
}
}
}

View File

@ -178,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
private bool isSplittable(PathControlPointPiece<T> p) =>
// A hit object can only be split on control points which connect two different path segments.
p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault();
p.ControlPoint.Type.HasValue && p.ControlPoint != controlPoints.FirstOrDefault() && p.ControlPoint != controlPoints.LastOrDefault();
private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e)
{

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 osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Osu.Edit.Blueprints;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class GridFromPointsTool : CompositionTool
{
public GridFromPointsTool()
: base("Grid")
{
TooltipText = """
Left click to set the origin.
Left click again to set the spacing and rotation.
Right click to reset to default.
Click and drag to set the origin, spacing and rotation.
""";
}
public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.DraftingCompass };
public override PlacementBlueprint CreatePlacementBlueprint() => new GridPlacementBlueprint();
}
}

View File

@ -11,12 +11,10 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
@ -40,7 +38,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{
MinValue = 0f,
MaxValue = OsuPlayfield.BASE_SIZE.X,
Precision = 1f
};
/// <summary>
@ -50,7 +47,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{
MinValue = 0f,
MaxValue = OsuPlayfield.BASE_SIZE.Y,
Precision = 1f
};
/// <summary>
@ -60,7 +56,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{
MinValue = 4f,
MaxValue = 128f,
Precision = 1f
};
/// <summary>
@ -70,14 +65,13 @@ namespace osu.Game.Rulesets.Osu.Edit
{
MinValue = -180f,
MaxValue = 180f,
Precision = 1f
};
/// <summary>
/// Read-only bindable representing the grid's origin.
/// Equivalent to <code>new Vector2(StartPositionX, StartPositionY)</code>
/// </summary>
public Bindable<Vector2> StartPosition { get; } = new Bindable<Vector2>();
public Bindable<Vector2> StartPosition { get; } = new Bindable<Vector2>(OsuPlayfield.BASE_SIZE / 2);
/// <summary>
/// Read-only bindable representing the grid's spacing in both the X and Y dimension.
@ -93,8 +87,6 @@ namespace osu.Game.Rulesets.Osu.Edit
private ExpandableSlider<float> gridLinesRotationSlider = null!;
private EditorRadioButtonCollection gridTypeButtons = null!;
private ExpandableButton useSelectedObjectPositionButton = null!;
public OsuGridToolboxGroup()
: base("grid")
{
@ -102,6 +94,26 @@ namespace osu.Game.Rulesets.Osu.Edit
private const float max_automatic_spacing = 64;
public void SetGridFromPoints(Vector2 point1, Vector2 point2)
{
StartPositionX.Value = point1.X;
StartPositionY.Value = point1.Y;
// Get the angle between the two points and normalize to the valid range.
if (!GridLinesRotation.Disabled)
{
float period = GridLinesRotation.MaxValue - GridLinesRotation.MinValue;
GridLinesRotation.Value = normalizeRotation(MathHelper.RadiansToDegrees(MathF.Atan2(point2.Y - point1.Y, point2.X - point1.X)), period);
}
// Divide the distance so that there is a good density of grid lines.
// This matches the maximum grid size of the grid size cycling hotkey.
float dist = Vector2.Distance(point1, point2);
while (dist >= max_automatic_spacing)
dist /= 2;
Spacing.Value = dist;
}
[BackgroundDependencyLoader]
private void load()
{
@ -117,20 +129,6 @@ namespace osu.Game.Rulesets.Osu.Edit
Current = StartPositionY,
KeyboardStep = 1,
},
useSelectedObjectPositionButton = new ExpandableButton
{
ExpandedLabelText = "Centre on selected object",
Action = () =>
{
if (editorBeatmap.SelectedHitObjects.Count != 1)
return;
var position = ((IHasPosition)editorBeatmap.SelectedHitObjects.Single()).Position;
StartPosition.Value = new Vector2(MathF.Round(position.X), MathF.Round(position.Y));
updateEnabledStates();
},
RelativeSizeAxes = Axes.X,
},
spacingSlider = new ExpandableSlider<float>
{
Current = Spacing,
@ -179,15 +177,15 @@ namespace osu.Game.Rulesets.Osu.Edit
StartPositionX.BindValueChanged(x =>
{
startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:N0}";
startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:N0}";
startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:#,0.##}";
startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:#,0.##}";
StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y);
}, true);
StartPositionY.BindValueChanged(y =>
{
startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:N0}";
startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:N0}";
startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:#,0.##}";
startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:#,0.##}";
StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue);
}, true);
@ -195,13 +193,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
StartPositionX.Value = pos.NewValue.X;
StartPositionY.Value = pos.NewValue.Y;
updateEnabledStates();
});
Spacing.BindValueChanged(spacing =>
{
spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:N0}";
spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:N0}";
spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:#,0.##}";
spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:#,0.##}";
SpacingVector.Value = new Vector2(spacing.NewValue);
editorBeatmap.BeatmapInfo.GridSize = (int)spacing.NewValue;
}, true);
@ -216,50 +213,46 @@ namespace osu.Game.Rulesets.Osu.Edit
{
GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle;
gridTypeButtons.Items[(int)v.NewValue].Select();
switch (v.NewValue)
{
case PositionSnapGridType.Square:
GridLinesRotation.Value = ((GridLinesRotation.Value + 405) % 90) - 45;
GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 90);
GridLinesRotation.MinValue = -45;
GridLinesRotation.MaxValue = 45;
break;
case PositionSnapGridType.Triangle:
GridLinesRotation.Value = ((GridLinesRotation.Value + 390) % 60) - 30;
GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 60);
GridLinesRotation.MinValue = -30;
GridLinesRotation.MaxValue = 30;
break;
}
}, true);
editorBeatmap.BeatmapReprocessed += updateEnabledStates;
editorBeatmap.SelectedHitObjects.BindCollectionChanged((_, _) => updateEnabledStates());
expandingContainer?.Expanded.BindValueChanged(v =>
{
gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint);
gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None;
updateEnabledStates();
}, true);
}
private void updateEnabledStates()
private float normalizeRotation(float rotation, float period)
{
useSelectedObjectPositionButton.Enabled.Value = expandingContainer?.Expanded.Value == true
&& editorBeatmap.SelectedHitObjects.Count == 1
&& !Precision.AlmostEquals(StartPosition.Value, ((IHasPosition)editorBeatmap.SelectedHitObjects.Single()).Position, 0.5f);
}
private void nextGridSize()
{
Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2;
return ((rotation + 360 + period * 0.5f) % period) - period * 0.5f;
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.EditorCycleGridDisplayMode:
nextGridSize();
case GlobalAction.EditorCycleGridSpacing:
Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2;
return true;
case GlobalAction.EditorCycleGridType:
GridType.Value = (PositionSnapGridType)(((int)GridType.Value + 1) % Enum.GetValues<PositionSnapGridType>().Length);
return true;
}

View File

@ -45,7 +45,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
new HitCircleCompositionTool(),
new SliderCompositionTool(),
new SpinnerCompositionTool()
new SpinnerCompositionTool(),
new GridFromPointsTool()
};
private readonly Bindable<TernaryState> rectangularGridSnapToggle = new Bindable<TernaryState>();
@ -79,13 +80,12 @@ namespace osu.Game.Rulesets.Osu.Edit
// Give a bit of breathing room around the playfield content.
PlayfieldContentContainer.Padding = new MarginPadding(10);
LayerBelowRuleset.AddRange(new Drawable[]
{
LayerBelowRuleset.Add(
distanceSnapGridContainer = new Container
{
RelativeSizeAxes = Axes.Both
}
});
);
selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy();
selectedHitObjects.CollectionChanged += (_, _) => updateDistanceSnapGrid();

View File

@ -240,39 +240,74 @@ namespace osu.Game.Rulesets.Osu.Edit
points = originalConvexHull!;
foreach (var point in points)
{
scale = clampToBound(scale, point, Vector2.Zero);
scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
}
scale = clampToBounds(scale, point, Vector2.Zero, OsuPlayfield.BASE_SIZE);
return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
return scale;
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)
// Clamps the scale vector s such that the point p scaled by s is within the rectangle defined by lowerBounds and upperBounds
Vector2 clampToBounds(Vector2 s, Vector2 p, Vector2 lowerBounds, Vector2 upperBounds)
{
p -= actualOrigin;
bound -= actualOrigin;
lowerBounds -= actualOrigin;
upperBounds -= actualOrigin;
// a.X is the rotated X component of p with respect to the X bounds
// a.Y is the rotated X component of p with respect to the Y bounds
// b.X is the rotated Y component of p with respect to the X bounds
// b.Y is the rotated Y component of p with respect to the Y bounds
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);
float sLowerBound, sUpperBound;
switch (adjustAxis)
{
case Axes.X:
s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a)));
(sLowerBound, sUpperBound) = computeBounds(lowerBounds - b, upperBounds - b, a);
s.X = MathHelper.Clamp(s.X, sLowerBound, sUpperBound);
break;
case Axes.Y:
s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b)));
(sLowerBound, sUpperBound) = computeBounds(lowerBounds - a, upperBounds - a, b);
s.Y = MathHelper.Clamp(s.Y, sLowerBound, sUpperBound);
break;
case Axes.Both:
s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y)));
// Here we compute the bounds for the magnitude multiplier of the scale vector
// Therefore the ratio s.X / s.Y will be maintained
(sLowerBound, sUpperBound) = computeBounds(lowerBounds, upperBounds, a * s.X + b * s.Y);
s.X = s.X < 0
? MathHelper.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound)
: MathHelper.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound);
s.Y = s.Y < 0
? MathHelper.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound)
: MathHelper.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound);
break;
}
return s;
}
// Computes the bounds for the magnitude of the scaled point p with respect to the bounds lowerBounds and upperBounds
(float, float) computeBounds(Vector2 lowerBounds, Vector2 upperBounds, Vector2 p)
{
var sLowerBounds = Vector2.Divide(lowerBounds, p);
var sUpperBounds = Vector2.Divide(upperBounds, p);
// If the point is negative, then the bounds are flipped
if (p.X < 0)
(sLowerBounds.X, sUpperBounds.X) = (sUpperBounds.X, sLowerBounds.X);
if (p.Y < 0)
(sLowerBounds.Y, sUpperBounds.Y) = (sUpperBounds.Y, sLowerBounds.Y);
// If the point is at zero, then any scale will have no effect on the point so the bounds are infinite
// The float division would already give us infinity for the bounds, but the sign is not consistent so we have to manually set it
if (Precision.AlmostEquals(p.X, 0))
(sLowerBounds.X, sUpperBounds.X) = (float.NegativeInfinity, float.PositiveInfinity);
if (Precision.AlmostEquals(p.Y, 0))
(sLowerBounds.Y, sUpperBounds.Y) = (float.NegativeInfinity, float.PositiveInfinity);
return (MathF.Max(sLowerBounds.X, sLowerBounds.Y), MathF.Min(sUpperBounds.X, sUpperBounds.Y));
}
}
private void moveSelectionInBounds()

View File

@ -136,8 +136,26 @@ namespace osu.Game.Rulesets.Osu.Edit
});
scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue });
xCheckBox.Current.BindValueChanged(x => setAxis(x.NewValue, yCheckBox.Current.Value));
yCheckBox.Current.BindValueChanged(y => setAxis(xCheckBox.Current.Value, y.NewValue));
xCheckBox.Current.BindValueChanged(_ =>
{
if (!xCheckBox.Current.Value && !yCheckBox.Current.Value)
{
yCheckBox.Current.Value = true;
return;
}
updateAxes();
});
yCheckBox.Current.BindValueChanged(_ =>
{
if (!xCheckBox.Current.Value && !yCheckBox.Current.Value)
{
xCheckBox.Current.Value = true;
return;
}
updateAxes();
});
selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
@ -152,6 +170,12 @@ namespace osu.Game.Rulesets.Osu.Edit
});
}
private void updateAxes()
{
scaleInfo.Value = scaleInfo.Value with { XAxis = xCheckBox.Current.Value, YAxis = yCheckBox.Current.Value };
updateMinMaxScale();
}
private void updateAxisCheckBoxesEnabled()
{
if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
@ -175,12 +199,14 @@ namespace osu.Game.Rulesets.Osu.Edit
axisBindable.Disabled = !available;
}
private void updateMaxScale()
private void updateMinMaxScale()
{
if (!scaleHandler.OriginalSurroundingQuad.HasValue)
return;
const float min_scale = 0.5f;
const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis)
@ -189,12 +215,21 @@ namespace osu.Game.Rulesets.Osu.Edit
scale.Y = max_scale;
scaleInputBindable.MaxValue = MathF.Max(1, MathF.Min(scale.X, scale.Y));
scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(min_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis)
scale.X = min_scale;
if (!scaleInfo.Value.YAxis)
scale.Y = min_scale;
scaleInputBindable.MinValue = MathF.Min(1, MathF.Max(scale.X, scale.Y));
}
private void setOrigin(ScaleOrigin origin)
{
scaleInfo.Value = scaleInfo.Value with { Origin = origin };
updateMaxScale();
updateMinMaxScale();
updateAxisCheckBoxesEnabled();
}
@ -219,21 +254,26 @@ namespace osu.Game.Rulesets.Osu.Edit
}
}
private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y;
private Axes getAdjustAxis(PreciseScaleInfo scale)
{
var result = Axes.None;
if (scale.XAxis)
result |= Axes.X;
if (scale.YAxis)
result |= Axes.Y;
return result;
}
private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
private void setAxis(bool x, bool y)
{
scaleInfo.Value = scaleInfo.Value with { XAxis = x, YAxis = y };
updateMaxScale();
}
protected override void PopIn()
{
base.PopIn();
scaleHandler.Begin();
updateMaxScale();
updateMinMaxScale();
}
protected override void PopOut()

View File

@ -120,6 +120,7 @@ namespace osu.Game.Rulesets.Osu.Mods
Position = Position + Path.PositionAt(e.PathProgress),
StackHeight = StackHeight,
Scale = Scale,
PathProgress = e.PathProgress,
});
break;
@ -150,6 +151,7 @@ namespace osu.Game.Rulesets.Osu.Mods
Position = Position + Path.PositionAt(e.PathProgress),
StackHeight = StackHeight,
Scale = Scale,
PathProgress = e.PathProgress,
});
break;
}

View File

@ -3,7 +3,6 @@
using System;
using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps;
@ -117,10 +116,9 @@ namespace osu.Game.Rulesets.Osu.Utils
if (osuObject is not Slider slider)
return;
void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - nested.Position.X, nested.Position.Y);
static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y);
modifySlider(slider, reflectNestedObject, reflectControlPoint);
modifySlider(slider, reflectControlPoint);
}
/// <summary>
@ -134,10 +132,9 @@ namespace osu.Game.Rulesets.Osu.Utils
if (osuObject is not Slider slider)
return;
void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(nested.Position.X, OsuPlayfield.BASE_SIZE.Y - nested.Position.Y);
static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(point.Position.X, -point.Position.Y);
modifySlider(slider, reflectNestedObject, reflectControlPoint);
modifySlider(slider, reflectControlPoint);
}
/// <summary>
@ -146,10 +143,9 @@ namespace osu.Game.Rulesets.Osu.Utils
/// <param name="slider">The slider to be flipped.</param>
public static void FlipSliderInPlaceHorizontally(Slider slider)
{
void flipNestedObject(OsuHitObject nested) => nested.Position = new Vector2(slider.X - (nested.X - slider.X), nested.Y);
static void flipControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y);
modifySlider(slider, flipNestedObject, flipControlPoint);
modifySlider(slider, flipControlPoint);
}
/// <summary>
@ -159,18 +155,13 @@ namespace osu.Game.Rulesets.Osu.Utils
/// <param name="rotation">The angle, measured in radians, to rotate the slider by.</param>
public static void RotateSlider(Slider slider, float rotation)
{
void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position;
void rotateControlPoint(PathControlPoint point) => point.Position = rotateVector(point.Position, rotation);
modifySlider(slider, rotateNestedObject, rotateControlPoint);
modifySlider(slider, rotateControlPoint);
}
private static void modifySlider(Slider slider, Action<OsuHitObject> modifyNestedObject, Action<PathControlPoint> modifyControlPoint)
private static void modifySlider(Slider slider, Action<PathControlPoint> modifyControlPoint)
{
// No need to update the head and tail circles, since slider handles that when the new slider path is set
slider.NestedHitObjects.OfType<SliderTick>().ForEach(modifyNestedObject);
slider.NestedHitObjects.OfType<SliderRepeat>().ForEach(modifyNestedObject);
var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray();
foreach (var point in controlPoints)
modifyControlPoint(point);

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier;
public override int Version => 20221107;
public override int Version => 20241007;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)

View File

@ -22,9 +22,9 @@ namespace osu.Game.Tests.Database
[HeadlessTest]
public partial class BackgroundDataStoreProcessorTests : OsuTestScene, ILocalUserPlayInfo
{
public IBindable<bool> IsPlaying => isPlaying;
public IBindable<LocalUserPlayingState> PlayingState => isPlaying;
private readonly Bindable<bool> isPlaying = new Bindable<bool>();
private readonly Bindable<LocalUserPlayingState> isPlaying = new Bindable<LocalUserPlayingState>();
private BeatmapSetInfo importedSet = null!;
@ -37,7 +37,7 @@ namespace osu.Game.Tests.Database
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Set not playing", () => isPlaying.Value = false);
AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying);
}
[Test]
@ -89,7 +89,7 @@ namespace osu.Game.Tests.Database
});
});
AddStep("Set playing", () => isPlaying.Value = true);
AddStep("Set playing", () => isPlaying.Value = LocalUserPlayingState.Playing);
AddStep("Reset difficulty", () =>
{
@ -117,7 +117,7 @@ namespace osu.Game.Tests.Database
});
});
AddStep("Set not playing", () => isPlaying.Value = false);
AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying);
AddUntilStep("wait for difficulties repopulated", () =>
{

View File

@ -3,11 +3,13 @@
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Configuration;
using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Input;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Input
@ -15,9 +17,20 @@ namespace osu.Game.Tests.Input
[HeadlessTest]
public partial class ConfineMouseTrackerTest : OsuGameTestScene
{
private readonly Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
[Resolved]
private FrameworkConfigManager frameworkConfigManager { get; set; } = null!;
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
// a bit dodgy.
AddStep("bind playing state", () => ((IBindable<LocalUserPlayingState>)playingState).BindTo(((ILocalUserPlayInfo)Game).PlayingState));
}
[TestCase(WindowMode.Windowed)]
[TestCase(WindowMode.Borderless)]
public void TestDisableConfining(WindowMode windowMode)
@ -88,7 +101,7 @@ namespace osu.Game.Tests.Input
=> AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode));
private void setLocalUserPlayingTo(bool playing)
=> AddStep($"local user {(playing ? "playing" : "not playing")}", () => Game.LocalUserPlaying.Value = playing);
=> AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying);
private void gameSideModeIs(OsuConfineMouseMode mode)
=> AddAssert($"mode is {mode} game-side", () => Game.LocalConfig.Get<OsuConfineMouseMode>(OsuSetting.ConfineMouseMode) == mode);

View File

@ -627,6 +627,87 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min);
}
private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) =>
new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero);
private static readonly object[] ranked_date_valid_test_cases =
{
new object[] { "ranked<2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<=2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<=2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked<=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked>2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>=2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked>=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max },
new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
};
[Test]
[TestCaseSource(nameof(ranked_date_valid_test_cases))]
public void TestValidRankedDateQueries(string query, DateTimeOffset expected, Func<FilterCriteria, DateTimeOffset?> f)
{
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(true, filterCriteria.DateRanked.HasFilter);
Assert.AreEqual(expected, f(filterCriteria));
}
private static readonly object[] ranked_date_invalid_test_cases =
{
new object[] { "ranked<0" },
new object[] { "ranked=99999" },
new object[] { "ranked>=2012-03-05-04" },
};
[Test]
[TestCaseSource(nameof(ranked_date_invalid_test_cases))]
public void TestInvalidRankedDateQueries(string query)
{
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(false, filterCriteria.DateRanked.HasFilter);
}
private static readonly object[] submitted_date_test_cases =
{
new object[] { "submitted<2012", true },
new object[] { "submitted<2012.03", true },
new object[] { "submitted<2012/03/05", true },
new object[] { "submitted<2012-3-5", true },
new object[] { "submitted<0", false },
new object[] { "submitted=99999", false },
new object[] { "submitted>=2012-03-05-04", false },
new object[] { "submitted>=2012/03.05-04", false },
};
[Test]
[TestCaseSource(nameof(submitted_date_test_cases))]
public void TestInvalidRankedDateQueries(string query, bool expected)
{
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter);
}
private static readonly object[] played_query_tests =
{
new object[] { "0", DateTimeOffset.MinValue, true },

View File

@ -6,10 +6,12 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Screens;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Metadata;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Metadata;
@ -81,6 +83,38 @@ namespace osu.Game.Tests.Visual.DailyChallenge
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null);
AddAssert("notification posted", () => notificationOverlay.AllNotifications.OfType<SimpleNotification>().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification));
}
[Test]
public void TestConclusionNotificationDoesNotFireOnDisconnect()
{
var room = new Room
{
RoomID = { Value = 1234 },
Name = { Value = "Daily Challenge: June 4, 2024" },
Playlist =
{
new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First())
{
RequiredMods = [new APIMod(new OsuModTraceable())],
AllowedMods = [new APIMod(new OsuModDoubleTime())]
}
},
EndDate = { Value = DateTimeOffset.Now.AddHours(12) },
Category = { Value = RoomCategory.DailyChallenge }
};
AddStep("add room", () => API.Perform(new CreateRoomRequest(room)));
AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 });
Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!;
AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
AddStep("disconnect from metadata server", () => metadataClient.Disconnect());
AddUntilStep("wait for disconnection", () => metadataClient.DailyChallengeInfo.Value, () => Is.Null);
AddAssert("no notification posted", () => notificationOverlay.AllNotifications, () => Is.Empty);
AddStep("reconnect to metadata server", () => metadataClient.Reconnect());
}
}
}

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Linq;
using NUnit.Framework;
using osu.Game.Overlays;
@ -12,7 +10,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
public partial class TestSceneOverlayActivation : OsuPlayerTestScene
{
protected new OverlayTestPlayer Player => base.Player as OverlayTestPlayer;
protected new OverlayTestPlayer Player => (OverlayTestPlayer)base.Player;
public override void SetUpSteps()
{

View File

@ -79,5 +79,114 @@ namespace osu.Game.Tests.Visual.Menus
trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next);
AddAssert("track actually changed", () => !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
[Test]
public void TestShuffleBackwards()
{
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
AddStep("ensure nonzero track duration", () => Game.Realm.Write(r =>
{
// this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`),
// but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`.
// do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore.
foreach (var beatmap in r.All<BeatmapInfo>().Where(b => b.Length == 0))
beatmap.Length = 60_000;
}));
AddStep("bind to track change", () =>
{
trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>();
Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
});
AddStep("seek track to 6 second", () => Game.MusicController.SeekTo(6000));
AddUntilStep("wait for current time to update", () => Game.MusicController.CurrentTrack.CurrentTime > 5000);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddAssert("no track change", () => trackChangeQueue.Count == 0);
AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () => trackChangeQueue.Count == 1);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () => trackChangeQueue.Count == 2);
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () =>
trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
[Test]
public void TestShuffleForwards()
{
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
AddStep("ensure nonzero track duration", () => Game.Realm.Write(r =>
{
// this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`),
// but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`.
// do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore.
foreach (var beatmap in r.All<BeatmapInfo>().Where(b => b.Length == 0))
beatmap.Length = 60_000;
}));
AddStep("bind to track change", () =>
{
trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>();
Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
});
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () => trackChangeQueue.Count == 1);
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () => trackChangeQueue.Count == 2);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () =>
trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
[Test]
public void TestShuffleBackAndForth()
{
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
AddStep("ensure nonzero track duration", () => Game.Realm.Write(r =>
{
// this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`),
// but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`.
// do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore.
foreach (var beatmap in r.All<BeatmapInfo>().Where(b => b.Length == 0))
beatmap.Length = 60_000;
}));
AddStep("bind to track change", () =>
{
trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>();
Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
});
AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
AddUntilStep("track changed", () => trackChangeQueue.Count == 1);
AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
AddUntilStep("track changed", () =>
trackChangeQueue.Count == 2 && !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo));
}
}
}

View File

@ -25,14 +25,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Cached(typeof(ILocalUserPlayInfo))]
private ILocalUserPlayInfo localUserInfo;
private readonly Bindable<bool> localUserPlaying = new Bindable<bool>();
private readonly Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
private TextBox textBox => chatDisplay.ChildrenOfType<TextBox>().First();
public TestSceneGameplayChatDisplay()
{
var mockLocalUserInfo = new Mock<ILocalUserPlayInfo>();
mockLocalUserInfo.SetupGet(i => i.IsPlaying).Returns(localUserPlaying);
mockLocalUserInfo.SetupGet(i => i.PlayingState).Returns(playingState);
localUserInfo = mockLocalUserInfo.Object;
}
@ -124,6 +124,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused);
private void setLocalUserPlaying(bool playing) =>
AddStep($"local user {(playing ? "playing" : "not playing")}", () => localUserPlaying.Value = playing);
AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying);
}
}

View File

@ -8,11 +8,13 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
@ -90,6 +92,48 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("no best score displayed", () => scoresContainer.ChildrenOfType<DrawableTopScore>().Count() == 1);
}
[Test]
public void TestHitResultsWithSameNameAreGrouped()
{
AddStep("Load scores without user best", () =>
{
var allScores = createScores();
allScores.UserScore = null;
scoresContainer.Scores = allScores;
});
AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType<ScoreTableRowBackground>().Any());
AddAssert("only one column for slider end", () =>
{
ScoreTable scoreTable = scoresContainer.ChildrenOfType<ScoreTable>().First();
return scoreTable.Columns.Count(c => c.Header.Equals("slider end")) == 1;
});
AddAssert("all rows show non-zero slider ends", () =>
{
ScoreTable scoreTable = scoresContainer.ChildrenOfType<ScoreTable>().First();
int sliderEndColumnIndex = Array.FindIndex(scoreTable.Columns, c => c != null && c.Header.Equals("slider end"));
bool sliderEndFilledInEachRow = true;
for (int i = 0; i < scoreTable.Content?.GetLength(0); i++)
{
switch (scoreTable.Content[i, sliderEndColumnIndex])
{
case OsuSpriteText text:
if (text.Text.Equals(0.0d.ToLocalisableString(@"N0")))
sliderEndFilledInEachRow = false;
break;
default:
sliderEndFilledInEachRow = false;
break;
}
}
return sliderEndFilledInEachRow;
});
}
[Test]
public void TestUserBest()
{
@ -287,13 +331,17 @@ namespace osu.Game.Tests.Visual.Online
const int initial_great_count = 2000;
const int initial_tick_count = 100;
const int initial_slider_end_count = 500;
int greatCount = initial_great_count;
int tickCount = initial_tick_count;
int sliderEndCount = initial_slider_end_count;
foreach (var s in scores.Scores)
foreach (var (score, index) in scores.Scores.Select((s, i) => (s, i)))
{
s.Statistics = new Dictionary<HitResult, int>
HitResult sliderEndResult = index % 2 == 0 ? HitResult.SliderTailHit : HitResult.SmallTickHit;
score.Statistics = new Dictionary<HitResult, int>
{
{ HitResult.Great, greatCount },
{ HitResult.LargeTickHit, tickCount },
@ -301,10 +349,19 @@ namespace osu.Game.Tests.Visual.Online
{ HitResult.Meh, RNG.Next(100) },
{ HitResult.Miss, initial_great_count - greatCount },
{ HitResult.LargeTickMiss, initial_tick_count - tickCount },
{ sliderEndResult, sliderEndCount },
};
// Some hit results, including SliderTailHit and SmallTickHit, are only displayed
// when the maximum number is known
score.MaximumStatistics = new Dictionary<HitResult, int>
{
{ sliderEndResult, initial_slider_end_count },
};
greatCount -= 100;
tickCount -= RNG.Next(1, 5);
sliderEndCount -= 20;
}
return scores;

View File

@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("select EZ mod", () =>
{
var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull();
SelectedMods.Value = new[] { ruleset.CreateMod<ModEasy>() };
advancedStats.Mods.Value = new[] { ruleset.CreateMod<ModEasy>() };
});
AddAssert("circle size bar is blue", () => barIsBlue(advancedStats.FirstValue));
@ -101,7 +101,7 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("select HR mod", () =>
{
var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull();
SelectedMods.Value = new[] { ruleset.CreateMod<ModHardRock>() };
advancedStats.Mods.Value = new[] { ruleset.CreateMod<ModHardRock>() };
});
AddAssert("circle size bar is red", () => barIsRed(advancedStats.FirstValue));
@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.SongSelect
var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull();
var difficultyAdjustMod = ruleset.CreateMod<ModDifficultyAdjust>().AsNonNull();
difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.Difficulty);
SelectedMods.Value = new[] { difficultyAdjustMod };
advancedStats.Mods.Value = new[] { difficultyAdjustMod };
});
AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue));
@ -143,7 +143,7 @@ namespace osu.Game.Tests.Visual.SongSelect
difficultyAdjustMod.ReadFromDifficulty(originalDifficulty);
difficultyAdjustMod.DrainRate.Value = originalDifficulty.DrainRate - 0.5f;
difficultyAdjustMod.ApproachRate.Value = originalDifficulty.ApproachRate + 2.2f;
SelectedMods.Value = new[] { difficultyAdjustMod };
advancedStats.Mods.Value = new[] { difficultyAdjustMod };
});
AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue));

View File

@ -285,7 +285,8 @@ namespace osu.Game.Beatmaps
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapInfo? QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => Realm.Run(r => r.All<BeatmapInfo>().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach());
public BeatmapInfo? QueryBeatmap(Expression<Func<BeatmapInfo, bool>> query) => Realm.Run(r =>
r.All<BeatmapInfo>().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach());
/// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available.
@ -313,6 +314,23 @@ namespace osu.Game.Beatmaps
});
}
public void ResetAllOffsets()
{
const string reset_complete_message = "All offsets have been reset!";
Realm.Write(r =>
{
var items = r.All<BeatmapInfo>();
foreach (var beatmap in items)
{
if (beatmap.UserSettings.Offset != 0)
beatmap.UserSettings.Offset = 0;
}
PostNotification?.Invoke(new ProgressCompletionNotification { Text = reset_complete_message });
});
}
public void Delete(Expression<Func<BeatmapSetInfo, bool>>? filter = null, bool silent = false)
{
Realm.Run(r =>

View File

@ -606,7 +606,7 @@ namespace osu.Game.Database
{
// Importantly, also sleep if high performance session is active.
// If we don't do this, memory usage can become runaway due to GC running in a more lenient mode.
while (localUserPlayInfo?.IsPlaying.Value == true || highPerformanceSessionManager?.IsSessionActive == true)
while (localUserPlayInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying || highPerformanceSessionManager?.IsSessionActive == true)
{
Logger.Log("Background processing sleeping due to active gameplay...");
Thread.Sleep(TimeToSleepDuringGameplay);

View File

@ -44,7 +44,7 @@ namespace osu.Game.Database
{
if (changes == null)
{
if (detachedBeatmapSets.Count > 0 && sender.Count == 0)
if (sender is RealmResetEmptySet<BeatmapSetInfo>)
{
// Usually we'd reset stuff here, but doing so triggers a silly flow which ends up deadlocking realm.
// Additionally, user should not be at song select when realm is blocking all operations in the first place.

View File

@ -93,8 +93,9 @@ namespace osu.Game.Database
/// 40 2023-12-21 Add ScoreInfo.Version to keep track of which build scores were set on.
/// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances.
/// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction
/// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user.
/// </summary>
private const int schema_version = 42;
private const int schema_version = 43;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -568,7 +569,7 @@ namespace osu.Game.Database
lock (notificationsResetMap)
{
// Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing.
notificationsResetMap.Add(action, () => callback(new EmptyRealmSet<T>(), null));
notificationsResetMap.Add(action, () => callback(new RealmResetEmptySet<T>(), null));
}
return RegisterCustomSubscription(action);
@ -1192,6 +1193,21 @@ namespace osu.Game.Database
}
break;
case 43:
{
// Clear default bindings for "Toggle FPS Display",
// as it conflicts with "Convert to Stream" in the editor.
// Only apply change if set to the conflicting bind
// i.e. has been manually rebound by the user.
var keyBindings = migration.NewRealm.All<RealmKeyBinding>();
var toggleFpsBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleFPSDisplay);
if (toggleFpsBind != null && toggleFpsBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.F }))
migration.NewRealm.Remove(toggleFpsBind);
break;
}
}
Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

View File

@ -12,7 +12,13 @@ using Realms.Schema;
namespace osu.Game.Database
{
public class EmptyRealmSet<T> : IRealmCollection<T>
/// <summary>
/// This can arrive in <see cref="RealmAccess.RegisterForNotifications{T}"/> callbacks to imply that realm access has been reset.
/// </summary>
/// <remarks>
/// Usually implies that the original database may return soon and the callback can usually be silently ignored.
///</remarks>
public class RealmResetEmptySet<T> : IRealmCollection<T>
{
private IList<T> emptySet => Array.Empty<T>();

View File

@ -5,6 +5,7 @@
using System;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface
{
@ -20,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface
/// <param name="nextStateFunction">A function to inform what the next state should be when this item is clicked.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
protected TernaryStateMenuItem(string text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
protected TernaryStateMenuItem(LocalisableString text, Func<TernaryState, TernaryState> nextStateFunction, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, nextStateFunction, type, action)
{
}

View File

@ -4,6 +4,7 @@
#nullable disable
using System;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface
{
@ -18,7 +19,7 @@ namespace osu.Game.Graphics.UserInterface
/// <param name="text">The text to display.</param>
/// <param name="type">The type of action which this <see cref="TernaryStateMenuItem"/> performs.</param>
/// <param name="action">A delegate to be invoked when this <see cref="TernaryStateMenuItem"/> is pressed.</param>
public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
public TernaryStateRadioMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard, Action<TernaryState> action = null)
: base(text, getNextState, type, action)
{
}

View File

@ -101,7 +101,7 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay),
new KeyBinding(InputKey.None, GlobalAction.ToggleFPSDisplay),
new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar),
new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor),
@ -134,7 +134,8 @@ namespace osu.Game.Input.Bindings
new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.EditorCloneSelection),
new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft),
new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight),
new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode),
new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridSpacing),
new KeyBinding(new[] { InputKey.Shift, InputKey.G }, GlobalAction.EditorCycleGridType),
new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay),
new KeyBinding(new[] { InputKey.T }, GlobalAction.EditorTapForBPM),
new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally),
@ -368,8 +369,8 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChatFocus))]
ToggleChatFocus,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridDisplayMode))]
EditorCycleGridDisplayMode,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridSpacing))]
EditorCycleGridSpacing,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestGameplay))]
EditorTestGameplay,
@ -472,6 +473,9 @@ namespace osu.Game.Input.Bindings
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextSamplePoint))]
EditorSeekToNextSamplePoint,
[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridType))]
EditorCycleGridType,
}
public enum GlobalActionCategory

View File

@ -15,7 +15,7 @@ namespace osu.Game.Input
{
/// <summary>
/// Connects <see cref="OsuSetting.ConfineMouseMode"/> with <see cref="FrameworkSetting.ConfineMouseMode"/>.
/// If <see cref="OsuGame.LocalUserPlaying"/> is true, we should also confine the mouse cursor if it has been
/// If <see cref="ILocalUserPlayInfo.PlayingState"/> is playing, we should also confine the mouse cursor if it has been
/// requested with <see cref="OsuConfineMouseMode.DuringGameplay"/>.
/// </summary>
public partial class ConfineMouseTracker : Component
@ -25,7 +25,7 @@ namespace osu.Game.Input
private Bindable<bool> frameworkMinimiseOnFocusLossInFullscreen;
private Bindable<OsuConfineMouseMode> osuConfineMode;
private IBindable<bool> localUserPlaying;
private IBindable<LocalUserPlayingState> localUserPlaying;
[BackgroundDependencyLoader]
private void load(ILocalUserPlayInfo localUserInfo, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager)
@ -37,7 +37,7 @@ namespace osu.Game.Input
frameworkMinimiseOnFocusLossInFullscreen.BindValueChanged(_ => updateConfineMode());
osuConfineMode = osuConfigManager.GetBindable<OsuConfineMouseMode>(OsuSetting.ConfineMouseMode);
localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy();
localUserPlaying = localUserInfo.PlayingState.GetBoundCopy();
osuConfineMode.ValueChanged += _ => updateConfineMode();
localUserPlaying.BindValueChanged(_ => updateConfineMode(), true);
@ -63,7 +63,7 @@ namespace osu.Game.Input
break;
case OsuConfineMouseMode.DuringGameplay:
frameworkConfineMode.Value = localUserPlaying.Value ? ConfineMouseMode.Always : ConfineMouseMode.Never;
frameworkConfineMode.Value = localUserPlaying.Value == LocalUserPlayingState.Playing ? ConfineMouseMode.Always : ConfineMouseMode.Never;
break;
case OsuConfineMouseMode.Always:

View File

@ -3,15 +3,16 @@
using osu.Framework.Bindables;
using osu.Framework.Input;
using osu.Game.Screens.Play;
using osuTK.Input;
namespace osu.Game.Input
{
public partial class OsuUserInputManager : UserInputManager
{
protected override bool AllowRightClickFromLongTouch => !LocalUserPlaying.Value;
protected override bool AllowRightClickFromLongTouch => PlayingState.Value != LocalUserPlayingState.Playing;
public readonly BindableBool LocalUserPlaying = new BindableBool();
public readonly IBindable<LocalUserPlayingState> PlayingState = new Bindable<LocalUserPlayingState>();
internal OsuUserInputManager()
{

View File

@ -19,6 +19,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString BeatmapVideos => new TranslatableString(getKey(@"beatmap_videos"), @"Are you sure you want to delete all beatmaps videos? This cannot be undone!");
/// <summary>
/// "Are you sure you want to reset all local beatmap offsets? This cannot be undone!"
/// </summary>
public static LocalisableString Offsets => new TranslatableString(getKey(@"offsets"), @"Are you sure you want to reset all local beatmap offsets? This cannot be undone!");
/// <summary>
/// "Are you sure you want to delete all skins? This cannot be undone!"
/// </summary>

View File

@ -190,9 +190,14 @@ namespace osu.Game.Localisation
public static LocalisableString EditorCloneSelection => new TranslatableString(getKey(@"editor_clone_selection"), @"Clone selection");
/// <summary>
/// "Cycle grid display mode"
/// "Cycle grid spacing"
/// </summary>
public static LocalisableString EditorCycleGridDisplayMode => new TranslatableString(getKey(@"editor_cycle_grid_display_mode"), @"Cycle grid display mode");
public static LocalisableString EditorCycleGridSpacing => new TranslatableString(getKey(@"editor_cycle_grid_spacing"), @"Cycle grid spacing");
/// <summary>
/// "Cycle grid type"
/// </summary>
public static LocalisableString EditorCycleGridType => new TranslatableString(getKey(@"editor_cycle_grid_type"), @"Cycle grid type");
/// <summary>
/// "Test gameplay"

View File

@ -59,6 +59,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos");
/// <summary>
/// "Reset ALL beatmap offsets"
/// </summary>
public static LocalisableString ResetAllOffsets => new TranslatableString(getKey(@"reset_all_offsets"), @"Reset ALL beatmap offsets");
/// <summary>
/// "Delete ALL scores"
/// </summary>

View File

@ -49,6 +49,51 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString RevertToDefaultDescription => new TranslatableString(getKey(@"revert_to_default_description"), @"All layout elements for layers in the current screen will be reset to defaults.");
/// <summary>
/// "Closest"
/// </summary>
public static LocalisableString Closest => new TranslatableString(getKey(@"closest"), @"Closest");
/// <summary>
/// "Anchor"
/// </summary>
public static LocalisableString Anchor => new TranslatableString(getKey(@"anchor"), @"Anchor");
/// <summary>
/// "Origin"
/// </summary>
public static LocalisableString Origin => new TranslatableString(getKey(@"origin"), @"Origin");
/// <summary>
/// "Reset position"
/// </summary>
public static LocalisableString ResetPosition => new TranslatableString(getKey(@"reset_position"), @"Reset position");
/// <summary>
/// "Reset rotation"
/// </summary>
public static LocalisableString ResetRotation => new TranslatableString(getKey(@"reset_rotation"), @"Reset rotation");
/// <summary>
/// "Reset scale"
/// </summary>
public static LocalisableString ResetScale => new TranslatableString(getKey(@"reset_scale"), @"Reset scale");
/// <summary>
/// "Bring to front"
/// </summary>
public static LocalisableString BringToFront => new TranslatableString(getKey(@"bring_to_front"), @"Bring to front");
/// <summary>
/// "Send to back"
/// </summary>
public static LocalisableString SendToBack => new TranslatableString(getKey(@"send_to_back"), @"Send to back");
/// <summary>
/// "Current working layer"
/// </summary>
public static LocalisableString CurrentWorkingLayer => new TranslatableString(getKey(@"current_working_layer"), @"Current working layer");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -175,14 +175,9 @@ namespace osu.Game
/// </summary>
public readonly IBindable<OverlayActivation> OverlayActivationMode = new Bindable<OverlayActivation>();
/// <summary>
/// Whether the local user is currently interacting with the game in a way that should not be interrupted.
/// </summary>
/// <remarks>
/// This is exclusively managed by <see cref="Player"/>. If other components are mutating this state, a more
/// resilient method should be used to ensure correct state.
/// </remarks>
public Bindable<bool> LocalUserPlaying = new BindableBool();
IBindable<LocalUserPlayingState> ILocalUserPlayInfo.PlayingState => playingState;
private readonly Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
protected OsuScreenStack ScreenStack;
@ -302,7 +297,7 @@ namespace osu.Game
protected override UserInputManager CreateUserInputManager()
{
var userInputManager = base.CreateUserInputManager();
(userInputManager as OsuUserInputManager)?.LocalUserPlaying.BindTo(LocalUserPlaying);
(userInputManager as OsuUserInputManager)?.PlayingState.BindTo(playingState);
return userInputManager;
}
@ -391,11 +386,11 @@ namespace osu.Game
// Transfer any runtime changes back to configuration file.
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString();
LocalUserPlaying.BindValueChanged(p =>
playingState.BindValueChanged(p =>
{
BeatmapManager.PauseImports = p.NewValue;
SkinManager.PauseImports = p.NewValue;
ScoreManager.PauseImports = p.NewValue;
BeatmapManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying;
SkinManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying;
ScoreManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying;
}, true);
IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true);
@ -1553,6 +1548,16 @@ namespace osu.Game
scope.SetTag(@"screen", newScreen?.GetType().ReadableName() ?? @"none");
});
switch (current)
{
case Player player:
player.PlayingState.UnbindFrom(playingState);
// reset for sanity.
playingState.Value = LocalUserPlayingState.NotPlaying;
break;
}
switch (newScreen)
{
case IntroScreen intro:
@ -1565,14 +1570,15 @@ namespace osu.Game
versionManager?.Show();
break;
case Player player:
player.PlayingState.BindTo(playingState);
break;
default:
versionManager?.Hide();
break;
}
// reset on screen change for sanity.
LocalUserPlaying.Value = false;
if (current is IOsuScreen currentOsuScreen)
{
OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode);
@ -1621,7 +1627,5 @@ namespace osu.Game
if (newScreen == null)
Exit();
}
IBindable<bool> ILocalUserPlayInfo.IsPlaying => LocalUserPlaying;
}
}

View File

@ -541,7 +541,10 @@ namespace osu.Game
realmBlocker = realm.BlockAllOperations("migration");
success = true;
}
catch { }
catch (Exception ex)
{
Logger.Log($"Attempting to block all operations failed: {ex}", LoggingTarget.Database);
}
readyToRun.Set();
}, false);

View File

@ -98,7 +98,7 @@ namespace osu.Game.Overlays
apiUser.BindValueChanged(_ => Schedule(() =>
{
if (api.IsLoggedIn)
replaceResultsAreaContent(Drawable.Empty());
replaceResultsAreaContent(Empty());
}));
}

View File

@ -9,7 +9,6 @@ using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@ -58,9 +57,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
}
/// <summary>
/// The statistics that appear in the table, in order of appearance.
/// The names of the statistics that appear in the table. If multiple HitResults have the same
/// DisplayName (for example, "slider end" is the name for both <see cref="HitResult.SliderTailHit"/> and <see cref="HitResult.SmallTickHit"/>
/// in osu!) the name will only be listed once.
/// </summary>
private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>();
private readonly List<LocalisableString> statisticResultNames = new List<LocalisableString>();
private bool showPerformancePoints;
@ -72,7 +73,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
return;
showPerformancePoints = showPerformanceColumn;
statisticResultTypes.Clear();
statisticResultNames.Clear();
for (int i = 0; i < scores.Count; i++)
backgroundFlow.Add(new ScoreTableRowBackground(i, scores[i], row_height));
@ -105,20 +106,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
var ruleset = scores.First().Ruleset.CreateInstance();
foreach (var result in EnumExtensions.GetValuesInOrder<HitResult>())
foreach (var resultGroup in ruleset.GetHitResults().GroupBy(r => r.displayName))
{
if (!allScoreStatistics.Contains(result))
if (!resultGroup.Any(r => allScoreStatistics.Contains(r.result)))
continue;
// for the time being ignore bonus result types.
// this is not being sent from the API and will be empty in all cases.
if (result.IsBonus())
if (resultGroup.All(r => r.result.IsBonus()))
continue;
var displayName = ruleset.GetDisplayNameForHitResult(result);
columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60)));
statisticResultTypes.Add((result, displayName));
columns.Add(new TableColumn(resultGroup.Key, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60)));
statisticResultNames.Add(resultGroup.Key);
}
if (showPerformancePoints)
@ -167,14 +166,25 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
#pragma warning restore 618
};
var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result);
var availableStatistics = score.GetStatisticsForDisplay().ToLookup(tuple => tuple.DisplayName);
foreach (var result in statisticResultTypes)
foreach (var columnName in statisticResultNames)
{
if (!availableStatistics.TryGetValue(result.result, out var stat))
stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName);
int count = 0;
int? maxCount = null;
content.Add(new StatisticText(stat.Count, stat.MaxCount, @"N0") { Colour = stat.Count == 0 ? Color4.Gray : Color4.White });
if (availableStatistics.Contains(columnName))
{
maxCount = 0;
foreach (var s in availableStatistics[columnName])
{
count += s.Count;
maxCount += s.MaxCount;
}
}
content.Add(new StatisticText(count, maxCount, @"N0") { Colour = count == 0 ? Color4.Gray : Color4.White });
}
if (showPerformancePoints)

View File

@ -3,8 +3,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -16,8 +14,6 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.BeatmapSet;
using osu.Game.Overlays.BeatmapSet.Scores;
using osu.Game.Overlays.Comments;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select.Details;
using osuTK;
using osuTK.Graphics;
@ -37,14 +33,6 @@ namespace osu.Game.Overlays
private (BeatmapSetLookupType type, int id)? lastLookup;
/// <remarks>
/// Isolates the beatmap set overlay from the game-wide selected mods bindable
/// to avoid affecting the beatmap details section (i.e. <see cref="AdvancedStats.StatisticRow"/>).
/// </remarks>
[Cached]
[Cached(typeof(IBindable<IReadOnlyList<Mod>>))]
protected readonly Bindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
public BeatmapSetOverlay()
: base(OverlayColourScheme.Blue)
{

View File

@ -75,7 +75,7 @@ namespace osu.Game.Overlays.Chat
Height = LineHeight,
Colour = colourProvider?.Background5 ?? Colour4.White,
},
Drawable.Empty(),
Empty(),
new OsuSpriteText
{
Anchor = Anchor.CentreRight,
@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Chat
}
},
},
Drawable.Empty(),
Empty(),
new Circle
{
Anchor = Anchor.Centre,

View File

@ -72,8 +72,9 @@ namespace osu.Game.Overlays
private AudioFilter audioDuckFilter = null!;
private readonly Bindable<RandomSelectAlgorithm> randomSelectAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly List<BeatmapSetInfo> previousRandomSets = new List<BeatmapSetInfo>();
private readonly List<Live<BeatmapSetInfo>> previousRandomSets = new List<Live<BeatmapSetInfo>>();
private int randomHistoryDirection;
private int lastRandomTrackDirection;
[BackgroundDependencyLoader]
private void load(AudioManager audio, OsuConfigManager configManager)
@ -249,19 +250,19 @@ namespace osu.Game.Overlays
queuedDirection = TrackChangeDirection.Prev;
BeatmapSetInfo? playableSet;
Live<BeatmapSetInfo>? playableSet;
if (Shuffle.Value)
playableSet = getNextRandom(-1, allowProtectedTracks);
else
{
playableSet = getBeatmapSets().AsEnumerable().TakeWhile(i => !i.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Protected || allowProtectedTracks)
?? getBeatmapSets().AsEnumerable().LastOrDefault(s => !s.Protected || allowProtectedTracks);
playableSet = getBeatmapSets().TakeWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Value.Protected || allowProtectedTracks)
?? getBeatmapSets().LastOrDefault(s => !s.Value.Protected || allowProtectedTracks);
}
if (playableSet != null)
{
changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Beatmaps.First()));
changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Value.Beatmaps.First()));
restartTrack();
return PreviousTrackResult.Previous;
}
@ -345,19 +346,19 @@ namespace osu.Game.Overlays
queuedDirection = TrackChangeDirection.Next;
BeatmapSetInfo? playableSet;
Live<BeatmapSetInfo>? playableSet;
if (Shuffle.Value)
playableSet = getNextRandom(1, allowProtectedTracks);
else
{
playableSet = getBeatmapSets().AsEnumerable().SkipWhile(i => !i.Equals(current?.BeatmapSetInfo))
.Where(i => !i.Protected || allowProtectedTracks)
playableSet = getBeatmapSets().SkipWhile(i => !i.Value.Equals(current?.BeatmapSetInfo))
.Where(i => !i.Value.Protected || allowProtectedTracks)
.ElementAtOrDefault(1)
?? getBeatmapSets().AsEnumerable().FirstOrDefault(i => !i.Protected || allowProtectedTracks);
?? getBeatmapSets().FirstOrDefault(i => !i.Value.Protected || allowProtectedTracks);
}
var playableBeatmap = playableSet?.Beatmaps.FirstOrDefault();
var playableBeatmap = playableSet?.Value.Beatmaps.FirstOrDefault();
if (playableBeatmap != null)
{
@ -369,56 +370,82 @@ namespace osu.Game.Overlays
return false;
}
private BeatmapSetInfo? getNextRandom(int direction, bool allowProtectedTracks)
private Live<BeatmapSetInfo>? getNextRandom(int direction, bool allowProtectedTracks)
{
BeatmapSetInfo result;
var possibleSets = getBeatmapSets().AsEnumerable().Where(s => !s.Protected || allowProtectedTracks).ToArray();
if (possibleSets.Length == 0)
return null;
// condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero.
// if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back,
// or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward.
// in both cases, it means that we have a history of previous random selections that we can rewind.
if (randomHistoryDirection * direction < 0)
try
{
Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count);
result = previousRandomSets[^1];
previousRandomSets.RemoveAt(previousRandomSets.Count - 1);
randomHistoryDirection += direction;
return result;
}
Live<BeatmapSetInfo> result;
// if the early-return above didn't cover it, it means that we have no history to fall back on
// and need to actually choose something random.
switch (randomSelectAlgorithm.Value)
{
case RandomSelectAlgorithm.Random:
result = possibleSets[RNG.Next(possibleSets.Length)];
break;
var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToList();
case RandomSelectAlgorithm.RandomPermutation:
var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToArray();
if (possibleSets.Count == 0)
return null;
if (notYetPlayedSets.Length == 0)
// if there is only one possible set left, play it, even if it is the same as the current track.
// looping is preferable over playing nothing.
if (possibleSets.Count == 1)
return possibleSets.Single();
// now that we actually know there is a choice, do not allow the current track to be played again.
possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo));
// condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero.
// if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back,
// or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward.
// in both cases, it means that we have a history of previous random selections that we can rewind.
if (randomHistoryDirection * direction < 0)
{
Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count);
// if the user has been shuffling backwards and now going forwards (or vice versa),
// the topmost item from history needs to be discarded because it's the *current* track.
if (direction * lastRandomTrackDirection < 0)
{
notYetPlayedSets = possibleSets;
previousRandomSets.Clear();
randomHistoryDirection = 0;
previousRandomSets.RemoveAt(previousRandomSets.Count - 1);
randomHistoryDirection += direction;
}
result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Length)];
break;
if (previousRandomSets.Count > 0)
{
result = previousRandomSets[^1];
previousRandomSets.RemoveAt(previousRandomSets.Count - 1);
return result;
}
}
default:
throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm");
// if the early-return above didn't cover it, it means that we have no history to fall back on
// and need to actually choose something random.
switch (randomSelectAlgorithm.Value)
{
case RandomSelectAlgorithm.Random:
result = possibleSets[RNG.Next(possibleSets.Count)];
break;
case RandomSelectAlgorithm.RandomPermutation:
var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToList();
if (notYetPlayedSets.Count == 0)
{
notYetPlayedSets = possibleSets;
previousRandomSets.Clear();
randomHistoryDirection = 0;
}
result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Count)];
break;
default:
throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm");
}
previousRandomSets.Add(result);
return result;
}
finally
{
randomHistoryDirection += direction;
lastRandomTrackDirection = direction;
}
previousRandomSets.Add(result);
randomHistoryDirection += direction;
return result;
}
private void restartTrack()
@ -432,7 +459,9 @@ namespace osu.Game.Overlays
private TrackChangeDirection? queuedDirection;
private IQueryable<BeatmapSetInfo> getBeatmapSets() => realm.Realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending);
private IEnumerable<Live<BeatmapSetInfo>> getBeatmapSets() => realm.Realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending)
.AsEnumerable()
.Select(s => new RealmLive<BeatmapSetInfo>(s, realm));
private void changeBeatmap(WorkingBeatmap newWorking)
{
@ -459,8 +488,8 @@ namespace osu.Game.Overlays
else
{
// figure out the best direction based on order in playlist.
int last = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(current.BeatmapSetInfo)).Count();
int next = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(newWorking.BeatmapSetInfo)).Count();
int last = getBeatmapSets().TakeWhile(b => !b.Value.Equals(current.BeatmapSetInfo)).Count();
int next = getBeatmapSets().TakeWhile(b => !b.Value.Equals(newWorking.BeatmapSetInfo)).Count();
direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next;
}

View File

@ -16,6 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
private SettingsButton deleteBeatmapsButton = null!;
private SettingsButton deleteBeatmapVideosButton = null!;
private SettingsButton resetOffsetsButton = null!;
private SettingsButton restoreButton = null!;
private SettingsButton undeleteButton = null!;
@ -47,6 +48,20 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
}, DeleteConfirmationContentStrings.BeatmapVideos));
}
});
Add(resetOffsetsButton = new DangerousSettingsButton
{
Text = MaintenanceSettingsStrings.ResetAllOffsets,
Action = () =>
{
dialogOverlay?.Push(new MassDeleteConfirmationDialog(() =>
{
resetOffsetsButton.Enabled.Value = false;
Task.Run(beatmaps.ResetAllOffsets).ContinueWith(_ => Schedule(() => resetOffsetsButton.Enabled.Value = true));
}, DeleteConfirmationContentStrings.Offsets));
}
});
AddRange(new Drawable[]
{
restoreButton = new SettingsButton

View File

@ -20,13 +20,13 @@ namespace osu.Game.Overlays.Settings
Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS };
}
public LocalisableString TooltipText { get; set; }
public IEnumerable<string> Keywords { get; set; } = Array.Empty<string>();
public BindableBool CanBeShown { get; } = new BindableBool(true);
IBindable<bool> IConditionalFilterable.CanBeShown => CanBeShown;
public LocalisableString TooltipText { get; set; }
public override IEnumerable<LocalisableString> FilterTerms
{
get

View File

@ -66,7 +66,7 @@ namespace osu.Game.Overlays.Settings
[BackgroundDependencyLoader]
private void load()
{
Size = new Vector2(SettingsSidebar.EXPANDED_WIDTH);
Size = new Vector2(EXPANDED_WIDTH);
Padding = new MarginPadding(40);

View File

@ -361,7 +361,7 @@ namespace osu.Game.Overlays.SkinEditor
componentsSidebar.Children = new[]
{
new EditorSidebarSection("Current working layer")
new EditorSidebarSection(SkinEditorStrings.CurrentWorkingLayer)
{
Children = new Drawable[]
{

View File

@ -13,6 +13,7 @@ using osu.Game.Extensions;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Localisation;
using osu.Game.Skinning;
using osu.Game.Utils;
using osuTK;
@ -101,19 +102,19 @@ namespace osu.Game.Overlays.SkinEditor
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<ISerialisableDrawable>> selection)
{
var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors())
var closestItem = new TernaryStateRadioMenuItem(SkinEditorStrings.Closest, MenuItemType.Standard, _ => applyClosestAnchors())
{
State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) }
};
yield return new OsuMenuItem("Anchor")
yield return new OsuMenuItem(SkinEditorStrings.Anchor)
{
Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors)
.Prepend(closestItem)
.ToArray()
};
yield return originMenu = new OsuMenuItem("Origin");
yield return originMenu = new OsuMenuItem(SkinEditorStrings.Origin);
closestItem.State.BindValueChanged(s =>
{
@ -125,19 +126,19 @@ namespace osu.Game.Overlays.SkinEditor
yield return new OsuMenuItemSpacer();
yield return new OsuMenuItem("Reset position", MenuItemType.Standard, () =>
yield return new OsuMenuItem(SkinEditorStrings.ResetPosition, MenuItemType.Standard, () =>
{
foreach (var blueprint in SelectedBlueprints)
((Drawable)blueprint.Item).Position = Vector2.Zero;
});
yield return new OsuMenuItem("Reset rotation", MenuItemType.Standard, () =>
yield return new OsuMenuItem(SkinEditorStrings.ResetRotation, MenuItemType.Standard, () =>
{
foreach (var blueprint in SelectedBlueprints)
((Drawable)blueprint.Item).Rotation = 0;
});
yield return new OsuMenuItem("Reset scale", MenuItemType.Standard, () =>
yield return new OsuMenuItem(SkinEditorStrings.ResetScale, MenuItemType.Standard, () =>
{
foreach (var blueprint in SelectedBlueprints)
{
@ -153,9 +154,9 @@ namespace osu.Game.Overlays.SkinEditor
yield return new OsuMenuItemSpacer();
yield return new OsuMenuItem("Bring to front", MenuItemType.Standard, () => skinEditor.BringSelectionToFront());
yield return new OsuMenuItem(SkinEditorStrings.BringToFront, MenuItemType.Standard, () => skinEditor.BringSelectionToFront());
yield return new OsuMenuItem("Send to back", MenuItemType.Standard, () => skinEditor.SendSelectionToBack());
yield return new OsuMenuItem(SkinEditorStrings.SendToBack, MenuItemType.Standard, () => skinEditor.SendSelectionToBack());
yield return new OsuMenuItemSpacer();

View File

@ -90,6 +90,9 @@ namespace osu.Game.Rulesets.Edit
private Bindable<bool> autoSeekOnPlacement;
private readonly Bindable<bool> composerFocusMode = new Bindable<bool>();
[CanBeNull]
private RadioButton lastTool;
protected DrawableRuleset<TObject> DrawableRuleset { get; private set; }
protected HitObjectComposer(Ruleset ruleset)
@ -213,8 +216,7 @@ namespace osu.Game.Rulesets.Edit
},
};
toolboxCollection.Items = CompositionTools
.Prepend(new SelectTool())
toolboxCollection.Items = (CompositionTools.Prepend(new SelectTool()))
.Select(t => new HitObjectCompositionToolButton(t, () => toolSelected(t)))
.ToList();
@ -231,7 +233,7 @@ namespace osu.Game.Rulesets.Edit
sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Select(b => new DrawableTernaryButton(b)));
setSelectTool();
SetSelectTool();
EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged;
}
@ -256,7 +258,7 @@ namespace osu.Game.Rulesets.Edit
{
// it's important this is performed before the similar code in EditorRadioButton disables the button.
if (!timing.NewValue)
setSelectTool();
SetSelectTool();
});
EditorBeatmap.HasTiming.BindValueChanged(hasTiming =>
@ -460,14 +462,18 @@ namespace osu.Game.Rulesets.Edit
if (EditorBeatmap.SelectedHitObjects.Any())
{
// ensure in selection mode if a selection is made.
setSelectTool();
SetSelectTool();
}
}
private void setSelectTool() => toolboxCollection.Items.First().Select();
public void SetSelectTool() => toolboxCollection.Items.First().Select();
public void SetLastTool() => (lastTool ?? toolboxCollection.Items.First()).Select();
private void toolSelected(CompositionTool tool)
{
lastTool = toolboxCollection.Items.OfType<HitObjectCompositionToolButton>().FirstOrDefault(i => i.Tool == BlueprintContainer.CurrentTool);
BlueprintContainer.CurrentTool = tool;
if (!(tool is SelectTool))

View File

@ -71,6 +71,11 @@ namespace osu.Game.Rulesets.Edit
PlacementActive = PlacementState.Finished;
}
/// <summary>
/// Determines which objects to snap to for the snap result in <see cref="UpdateTimeAndPosition"/>.
/// </summary>
public virtual SnapType SnapType => SnapType.All;
/// <summary>
/// Updates the time and position of this <see cref="PlacementBlueprint"/> based on the provided snap information.
/// </summary>

View File

@ -297,7 +297,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
private void updatePlacementPosition()
{
var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position);
var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position, CurrentPlacement.SnapType);
// if no time was found from positional snapping, we should still quantize to the beat.
snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null);

View File

@ -150,13 +150,25 @@ namespace osu.Game.Screens.Edit.Compose.Components
switch (e.Key)
{
case Key.G:
return CanReverse && reverseButton?.TriggerClick() == true;
if (!CanReverse || reverseButton == null)
return false;
reverseButton.TriggerAction();
return true;
case Key.Comma:
return canRotate.Value && rotateCounterClockwiseButton?.TriggerClick() == true;
if (!canRotate.Value || rotateCounterClockwiseButton == null)
return false;
rotateCounterClockwiseButton.TriggerAction();
return true;
case Key.Period:
return canRotate.Value && rotateClockwiseButton?.TriggerClick() == true;
if (!canRotate.Value || rotateClockwiseButton == null)
return false;
rotateClockwiseButton.TriggerAction();
return true;
}
return base.OnKeyDown(e);
@ -285,7 +297,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
Action = action
};
button.OperationStarted += freezeButtonPosition;
button.Clicked += freezeButtonPosition;
button.HoverLost += unfreezeButtonPosition;
button.OperationStarted += operationStarted;

View File

@ -21,6 +21,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
public Action? Action;
public event Action? Clicked;
public event Action? HoverLost;
public SelectionBoxButton(IconUsage iconUsage, string tooltip)
@ -49,11 +51,10 @@ namespace osu.Game.Screens.Edit.Compose.Components
protected override bool OnClick(ClickEvent e)
{
Circle.FlashColour(Colours.GrayF, 300);
Clicked?.Invoke();
TriggerAction();
TriggerOperationStarted();
Action?.Invoke();
TriggerOperationEnded();
return true;
}
@ -71,5 +72,14 @@ namespace osu.Game.Screens.Edit.Compose.Components
}
public LocalisableString TooltipText { get; }
public void TriggerAction()
{
Circle.FlashColour(Colours.GrayF, 300);
TriggerOperationStarted();
Action?.Invoke();
TriggerOperationEnded();
}
}
}

View File

@ -410,7 +410,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge
private void dailyChallengeChanged(ValueChangedEvent<DailyChallengeInfo?> change)
{
if (change.OldValue?.RoomID == room.RoomID.Value && change.NewValue == null)
if (change.OldValue?.RoomID == room.RoomID.Value && change.NewValue == null && metadataClient.IsConnected.Value)
{
notificationOverlay?.Post(new SimpleNotification { Text = DailyChallengeStrings.ChallengeEndedNotification });
}

View File

@ -23,9 +23,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[CanBeNull]
private ILocalUserPlayInfo localUserInfo { get; set; }
private readonly IBindable<bool> localUserPlaying = new Bindable<bool>();
private readonly IBindable<LocalUserPlayingState> localUserPlaying = new Bindable<LocalUserPlayingState>();
public override bool PropagatePositionalInputSubTree => !localUserPlaying.Value;
public override bool PropagatePositionalInputSubTree => localUserPlaying.Value != LocalUserPlayingState.Playing;
public Bindable<bool> Expanded = new Bindable<bool>();
@ -58,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
base.LoadComplete();
if (localUserInfo != null)
localUserPlaying.BindTo(localUserInfo.IsPlaying);
localUserPlaying.BindTo(localUserInfo.PlayingState);
localUserPlaying.BindValueChanged(playing =>
{
@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
TextBox.HoldFocus = false;
// only hold focus (after sending a message) during breaks
TextBox.ReleaseFocusOnCommit = playing.NewValue;
TextBox.ReleaseFocusOnCommit = playing.NewValue == LocalUserPlayingState.Playing;
}, true);
Expanded.BindValueChanged(_ => updateExpandedState(), true);

View File

@ -41,6 +41,9 @@ namespace osu.Game.Screens.Play.HUD
{
get
{
if (!Interactive)
return default;
double progress = Math.Clamp(lastMouseX, 0, DrawWidth) / DrawWidth;
TimeSpan currentSpan = TimeSpan.FromMilliseconds(Math.Round((EndTime - StartTime) * progress));

View File

@ -299,7 +299,7 @@ namespace osu.Game.Screens.Play.HUD
{
case GlobalAction.Back:
if (!pendingAnimation)
BeginConfirm();
Confirm();
return true;
case GlobalAction.PauseGameplay:
@ -307,7 +307,7 @@ namespace osu.Game.Screens.Play.HUD
if (ReplayLoaded.Value) return false;
if (!pendingAnimation)
BeginConfirm();
Confirm();
return true;
}

View File

@ -10,8 +10,8 @@ namespace osu.Game.Screens.Play
public interface ILocalUserPlayInfo
{
/// <summary>
/// Whether the local user is currently playing.
/// Whether the local user is currently interacting (playing) with the game in a way that should not be interrupted.
/// </summary>
IBindable<bool> IsPlaying { get; }
IBindable<LocalUserPlayingState> PlayingState { get; }
}
}

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Screens.Play
{
public enum LocalUserPlayingState
{
/// <summary>
/// The local player is not current in gameplay. If watching a replay, gameplay always remains in this state.
/// </summary>
NotPlaying,
/// <summary>
/// The local player is in a break, paused, or failed but still at the gameplay screen.
/// </summary>
Break,
/// <summary>
/// The local user is in active gameplay.
/// </summary>
Playing,
}
}

View File

@ -94,6 +94,7 @@ namespace osu.Game.Screens.Play
public IBindable<bool> LocalUserPlaying => localUserPlaying;
private readonly Bindable<bool> localUserPlaying = new Bindable<bool>();
private readonly Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
public int RestartCount;
@ -231,9 +232,6 @@ namespace osu.Game.Screens.Play
if (game != null)
gameActive.BindTo(game.IsActive);
if (game is OsuGame osuGame)
LocalUserPlaying.BindTo(osuGame.LocalUserPlaying);
DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, gameplayMods);
dependencies.CacheAs(DrawableRuleset);
@ -510,9 +508,16 @@ namespace osu.Game.Screens.Play
private void updateGameplayState()
{
bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value && !GameplayState.HasFailed;
OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered;
localUserPlaying.Value = inGameplay;
bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !GameplayState.HasPassed && !GameplayState.HasFailed;
bool inBreak = breakTracker.IsBreakTime.Value || DrawableRuleset.IsPaused.Value;
if (inGameplay)
playingState.Value = inBreak ? LocalUserPlayingState.Break : LocalUserPlayingState.Playing;
else
playingState.Value = LocalUserPlayingState.NotPlaying;
localUserPlaying.Value = playingState.Value == LocalUserPlayingState.Playing;
OverlayActivationMode.Value = playingState.Value == LocalUserPlayingState.Playing ? OverlayActivation.Disabled : OverlayActivation.UserTriggered;
}
private void updateSampleDisabledState()
@ -1279,6 +1284,6 @@ namespace osu.Game.Screens.Play
IBindable<bool> ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled;
IBindable<bool> ILocalUserPlayInfo.IsPlaying => LocalUserPlaying;
public IBindable<LocalUserPlayingState> PlayingState => playingState;
}
}

View File

@ -455,7 +455,19 @@ namespace osu.Game.Screens.Play
MetadataInfo.Loading = true;
content.FadeInFromZero(500, Easing.OutQuint);
content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer);
if (quickRestart)
{
prepareNewPlayer();
content.ScaleTo(1, 650, Easing.OutQuint);
}
else
{
content
.ScaleTo(1, 650, Easing.OutQuint)
.Then()
.Schedule(prepareNewPlayer);
}
using (BeginDelayedSequence(delayBeforeSideDisplays))
{

View File

@ -14,7 +14,7 @@ namespace osu.Game.Screens.Select
public BeatmapDeleteDialog(BeatmapSetInfo beatmapSet)
{
this.beatmapSet = beatmapSet;
BodyText = $@"{beatmapSet.Metadata.Artist} - {beatmapSet.Metadata.Title}";
BodyText = beatmapSet.Metadata.GetDisplayTitleRomanisable(false);
}
[BackgroundDependencyLoader]

View File

@ -66,6 +66,8 @@ namespace osu.Game.Screens.Select.Carousel
match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.Difficulty.OverallDifficulty);
match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length);
match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(BeatmapInfo.LastPlayed ?? DateTimeOffset.MinValue);
match &= !criteria.DateRanked.HasFilter || (BeatmapInfo.BeatmapSet?.DateRanked != null && criteria.DateRanked.IsInRange(BeatmapInfo.BeatmapSet.DateRanked.Value));
match &= !criteria.DateSubmitted.HasFilter || (BeatmapInfo.BeatmapSet?.DateSubmitted != null && criteria.DateSubmitted.IsInRange(BeatmapInfo.BeatmapSet.DateSubmitted.Value));
match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM);
match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor);

View File

@ -3,6 +3,7 @@
#nullable disable
using System;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
@ -36,9 +37,6 @@ namespace osu.Game.Screens.Select.Details
[Resolved]
private BeatmapDifficultyCache difficultyCache { get; set; }
[Resolved]
private IBindable<IReadOnlyList<Mod>> mods { get; set; }
protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate;
private readonly StatisticRow starDifficulty;
@ -69,6 +67,14 @@ namespace osu.Game.Screens.Select.Details
/// </remarks>
public Bindable<RulesetInfo> Ruleset { get; } = new Bindable<RulesetInfo>();
/// <summary>
/// Mods to be used for certain elements of display.
/// </summary>
/// <remarks>
/// No checks are done as to whether the mods specified are valid for the current <see cref="Ruleset"/>.
/// </remarks>
public Bindable<IReadOnlyList<Mod>> Mods { get; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
public AdvancedStats(int columns = 1)
{
switch (columns)
@ -143,8 +149,7 @@ namespace osu.Game.Screens.Select.Details
base.LoadComplete();
Ruleset.BindValueChanged(_ => updateStatistics());
mods.BindValueChanged(modsChanged, true);
Mods.BindValueChanged(modsChanged, true);
}
private ModSettingChangeTracker modSettingChangeTracker;
@ -173,14 +178,14 @@ namespace osu.Game.Screens.Select.Details
{
BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty);
foreach (var mod in mods.Value.OfType<IApplicableToDifficulty>())
foreach (var mod in Mods.Value.OfType<IApplicableToDifficulty>())
mod.ApplyToDifficulty(originalDifficulty);
adjustedDifficulty = originalDifficulty;
if (Ruleset.Value != null)
{
double rate = ModUtils.CalculateRateWithMods(mods.Value);
double rate = ModUtils.CalculateRateWithMods(Mods.Value);
adjustedDifficulty = Ruleset.Value.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate);
@ -198,7 +203,7 @@ namespace osu.Game.Screens.Select.Details
// For the time being, the key count is static no matter what, because:
// a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering.
// b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion.
int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, mods.Value);
int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, Mods.Value);
FirstValue.Title = BeatmapsetsStrings.ShowStatsCsMania;
FirstValue.Value = (keyCount, keyCount);
@ -236,7 +241,7 @@ namespace osu.Game.Screens.Select.Details
starDifficultyCancellationSource = new CancellationTokenSource();
var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, null, starDifficultyCancellationSource.Token);
var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, mods.Value, starDifficultyCancellationSource.Token);
var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, Mods.Value, starDifficultyCancellationSource.Token);
Task.WhenAll(normalStarDifficultyTask, moddedStarDifficultyTask).ContinueWith(_ => Schedule(() =>
{

View File

@ -37,6 +37,8 @@ namespace osu.Game.Screens.Select
public OptionalRange<int> BeatDivisor;
public OptionalSet<BeatmapOnlineStatus> OnlineStatus = new OptionalSet<BeatmapOnlineStatus>();
public OptionalRange<DateTimeOffset> LastPlayed;
public OptionalRange<DateTimeOffset> DateRanked;
public OptionalRange<DateTimeOffset> DateSubmitted;
public OptionalTextFilter Creator;
public OptionalTextFilter Artist;
public OptionalTextFilter Title;

View File

@ -65,6 +65,12 @@ namespace osu.Game.Screens.Select
case "lastplayed":
return tryUpdateDateAgoRange(ref criteria.LastPlayed, op, value);
case "ranked":
return tryUpdateRankedDateRange(ref criteria.DateRanked, op, value);
case "submitted":
return tryUpdateRankedDateRange(ref criteria.DateSubmitted, op, value);
case "played":
if (!tryParseBool(value, out bool played))
return false;
@ -592,5 +598,163 @@ namespace osu.Game.Screens.Select
return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset.Value);
}
/// <summary>
/// Helper function for building a UTC date from only the year, month and day.
/// UTC is used to keep consistent search results with osu!web.
/// </summary>
private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) =>
new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// Parses a string containing a ranked or submitted date filter.
/// Returns a boolean depending on whether parsing was successful or not.
/// Accepted dates are in the formats `yyyy`, `yyyy-mm` and `yyyy-mm-dd`.
/// Leading zeros are accepted. Numbers can be separated by `-`, `/`, or `.`
/// </summary>
/// <param name="dateRange">The <see cref="FilterCriteria.OptionalRange{DateTimeOffset}"/> to store the parsed data into, if successful.</param>
/// <param name="op">The operator of the filtering query</param>
/// <param name="val">The string value to attempt parsing for.</param>
private static bool tryUpdateRankedDateRange(ref FilterCriteria.OptionalRange<DateTimeOffset> dateRange, Operator op, string val)
{
GroupCollection? match = tryMatchRegex(val, @"^(?<year>\d+)([-/.](?<month>\d+)([-/.](?<day>\d+))?)?$");
if (match == null)
return false;
int? year = null;
int? month = null;
int? day = null;
List<string> keys = new List<string> { @"year", @"month", @"day" };
foreach (string key in keys)
{
if (!match.TryGetValue(key, out var group) || !group.Success)
continue;
if (group.Success)
{
if (!tryParseDoubleWithPoint(group.Value, out double value))
return false;
switch (key)
{
case @"year":
year = (int)value;
break;
case @"month":
month = (int)value;
break;
case @"day":
day = (int)value;
break;
}
}
}
if (year == null)
{
return false;
}
try
{
DateTimeOffset dateTimeOffset;
switch (op)
{
case Operator.Less:
month ??= 1;
day ??= 1;
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value);
return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset);
case Operator.LessOrEqual:
if (month == null)
{
month = 1;
day = 1;
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset);
}
if (day == null)
{
day = 1;
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset);
}
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset);
case Operator.GreaterOrEqual:
month ??= 1;
day ??= 1;
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value);
return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset);
case Operator.Greater:
if (month == null)
{
month = 1;
day = 1;
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset);
}
if (day == null)
{
day = 1;
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset);
}
dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset);
case Operator.Equal:
DateTimeOffset minDateTimeOffset;
DateTimeOffset maxDateTimeOffset;
if (month == null)
{
month = 1;
day = 1;
minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value);
maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset)
&& tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset);
}
if (day == null)
{
day = 1;
minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value);
maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset)
&& tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset);
}
minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value);
maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1);
return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset)
&& tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset);
default:
return false;
}
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
}
}

View File

@ -610,11 +610,6 @@ namespace osu.Game.Screens.Select
beatmapInfoPrevious = beatmap;
}
// we can't run this in the debounced run due to the selected mods bindable not being debounced,
// since mods could be updated to the new ruleset instances while the decoupled bindable is held behind,
// therefore resulting in performing difficulty calculation with invalid states.
advancedStats.Ruleset.Value = ruleset;
void run()
{
// clear pending task immediately to track any potential nested debounce operation.
@ -878,6 +873,8 @@ namespace osu.Game.Screens.Select
ModSelect.Beatmap.Value = beatmap;
advancedStats.BeatmapInfo = beatmap.BeatmapInfo;
advancedStats.Mods.Value = selectedMods.Value;
advancedStats.Ruleset.Value = Ruleset.Value;
bool beatmapSelected = beatmap is not DummyWorkingBeatmap;
@ -990,6 +987,12 @@ namespace osu.Game.Screens.Select
Beatmap.BindValueChanged(updateCarouselSelection);
selectedMods.BindValueChanged(_ =>
{
if (decoupledRuleset.Value.Equals(rulesetNoDebounce))
advancedStats.Mods.Value = selectedMods.Value;
}, true);
boundLocalBindables = true;
}

View File

@ -13,7 +13,8 @@ namespace osu.Game.Tests.Visual.Metadata
{
public partial class TestMetadataClient : MetadataClient
{
public override IBindable<bool> IsConnected => new BindableBool(true);
public override IBindable<bool> IsConnected => isConnected;
private readonly BindableBool isConnected = new BindableBool(true);
public override IBindable<bool> IsWatchingUserPresence => isWatchingUserPresence;
private readonly BindableBool isWatchingUserPresence = new BindableBool();
@ -98,5 +99,16 @@ namespace osu.Game.Tests.Visual.Metadata
}
public override Task EndWatchingMultiplayerRoom(long id) => Task.CompletedTask;
public void Disconnect()
{
isConnected.Value = false;
dailyChallengeInfo.Value = null;
}
public void Reconnect()
{
isConnected.Value = true;
}
}
}

View File

@ -35,7 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.1007.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.1009.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.1003.0" />
<PackageReference Include="Sentry" Version="4.3.0" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->

View File

@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.1007.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.1009.0" />
</ItemGroup>
</Project>