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

Merge branch 'master' into auto-addition2

This commit is contained in:
Dean Herbert 2024-10-23 15:53:07 +09:00 committed by GitHub
commit 5939c78760
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
226 changed files with 5207 additions and 1156 deletions

View File

@ -133,10 +133,7 @@ jobs:
dotnet-version: "8.0.x" dotnet-version: "8.0.x"
- name: Install .NET Workloads - name: Install .NET Workloads
run: dotnet workload install maui-ios run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer
- name: Build - name: Build
run: dotnet build -c Debug osu.iOS run: dotnet build -c Debug osu.iOS

View File

@ -104,6 +104,25 @@ env:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
jobs: jobs:
master-environment:
name: Save master environment
runs-on: ubuntu-latest
outputs:
HEAD: ${{ steps.get-head.outputs.HEAD }}
steps:
- name: Checkout osu
uses: actions/checkout@v4
with:
ref: master
sparse-checkout: |
README.md
- name: Get HEAD ref
id: get-head
run: |
ref=$(git log -1 --format='%H')
echo "HEAD=https://github.com/${{ github.repository }}/commit/${ref}" >> "${GITHUB_OUTPUT}"
check-permissions: check-permissions:
name: Check permissions name: Check permissions
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -121,7 +140,7 @@ jobs:
create-comment: create-comment:
name: Create PR comment name: Create PR comment
needs: check-permissions needs: [ master-environment, check-permissions ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps: steps:
@ -158,7 +177,7 @@ jobs:
environment: environment:
name: Setup environment name: Setup environment
needs: directory needs: [ master-environment, directory ]
runs-on: self-hosted runs-on: self-hosted
env: env:
VARS_JSON: ${{ toJSON(vars) }} VARS_JSON: ${{ toJSON(vars) }}
@ -182,6 +201,10 @@ jobs:
fi fi
done done
- name: Add master environment
run: |
sed -i "s;^OSU_A=.*$;OSU_A=${{ needs.master-environment.outputs.HEAD }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Add pull-request environment - name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: | run: |
@ -250,22 +273,23 @@ jobs:
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache - name: Restore cache
id: restore-cache id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with: with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }} key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download - name: Download
if: steps.restore-cache.outputs.cache-hit != 'true' if: steps.restore-cache.outputs.cache-hit != 'true'
run: | 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 - name: Extract
run: | 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 }}" rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}" mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
@ -281,22 +305,23 @@ jobs:
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache - name: Restore cache
id: restore-cache id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with: with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }} key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download - name: Download
if: steps.restore-cache.outputs.cache-hit != 'true' if: steps.restore-cache.outputs.cache-hit != 'true'
run: | 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 - name: Extract
run: | 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 }}" rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}" mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
@ -316,9 +341,12 @@ jobs:
sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
cd "${{ needs.directory.outputs.GENERATOR_DIR }}" 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-) target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
echo "TARGET=${target}" >> "${GITHUB_OUTPUT}" echo "TARGET=${target}" >> "${GITHUB_OUTPUT}"
@ -328,7 +356,7 @@ jobs:
if: ${{ always() }} if: ${{ always() }}
run: | run: |
cd "${{ needs.directory.outputs.GENERATOR_DIR }}" cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose down -v docker compose down --volumes
output-cli: output-cli:
name: Output info name: Output info
@ -361,8 +389,7 @@ jobs:
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with: with:
comment_tag: ${{ env.EXECUTION_ID }} comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert mode: recreate
create_if_not_exists: false
message: | message: |
Target: ${{ needs.generator.outputs.TARGET }} Target: ${{ needs.generator.outputs.TARGET }}
Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }} Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
@ -372,8 +399,7 @@ jobs:
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with: with:
comment_tag: ${{ env.EXECUTION_ID }} comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert mode: recreate
create_if_not_exists: false
message: | message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -1,5 +1,6 @@
{ {
"recommendations": [ "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. - 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 ### Downloading the source code

View File

@ -9,7 +9,7 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -9,7 +9,7 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -9,7 +9,7 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -9,7 +9,7 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

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

View File

@ -6,28 +6,29 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game; using osu.Game;
using osu.Game.Screens.Play;
namespace osu.Android namespace osu.Android
{ {
public partial class GameplayScreenRotationLocker : Component public partial class GameplayScreenRotationLocker : Component
{ {
private Bindable<bool> localUserPlaying = null!; private IBindable<LocalUserPlayingState> localUserPlaying = null!;
[Resolved] [Resolved]
private OsuGameActivity gameActivity { get; set; } = null!; private OsuGameActivity gameActivity { get; set; } = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuGame game) private void load(ILocalUserPlayInfo localUserPlayInfo)
{ {
localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); localUserPlaying = localUserPlayInfo.PlayingState.GetBoundCopy();
localUserPlaying.BindValueChanged(updateLock, true); localUserPlaying.BindValueChanged(updateLock, true);
} }
private void updateLock(ValueChangedEvent<bool> userPlaying) private void updateLock(ValueChangedEvent<LocalUserPlayingState> userPlaying)
{ {
gameActivity.RunOnUiThread(() => 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? // 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). // 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, // Also, spaces don't count. Because reasons, clearly.
// just tack on enough of U+200B ZERO WIDTH SPACEs at the end. // That all seems very questionable, and isn't even documented anywhere. So to *make it* accept such valid input,
if (str.Length < 2) // just tack on enough of U+200B ZERO WIDTH SPACEs at the end. After making sure to trim whitespace.
return str.PadRight(2, '\u200B'); string trimmed = str.Trim();
if (trimmed.Length < 2)
return trimmed.PadRight(2, '\u200B');
if (Encoding.UTF8.GetByteCount(str) <= 128) if (Encoding.UTF8.GetByteCount(str) <= 128)
return str; return str;

View File

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

View File

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

View File

@ -24,7 +24,7 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="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="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.630-g9c52e40" /> <PackageReference Include="Velopack" Version="0.0.630-g9c52e40" />
</ItemGroup> </ItemGroup>

View File

@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" /> <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="nunit" Version="3.14.0" /> <PackageReference Include="nunit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

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

View File

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

View File

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

View File

@ -1,8 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Visual; using osu.Game.Tests.Visual;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Tests.Editor namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
@ -10,5 +21,66 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
public partial class TestSceneOsuEditor : EditorTestScene public partial class TestSceneOsuEditor : EditorTestScene
{ {
protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
[Test]
public void TestTouchInputAfterTouchingComposeArea()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
// this input is just for interacting with compose area
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddStep("move current time", () => InputManager.Key(Key.Right));
AddStep("tap to place circle", () => tap(this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(10, 10))));
AddAssert("circle placed correctly", () =>
{
var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
Assert.Multiple(() =>
{
Assert.That(circle.Position.X, Is.EqualTo(10f).Within(0.01f));
Assert.That(circle.Position.Y, Is.EqualTo(10f).Within(0.01f));
});
return true;
});
AddStep("tap slider", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Slider")));
// this input is just for interacting with compose area
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddStep("move current time", () => InputManager.Key(Key.Right));
AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(50, 20)))));
AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(200, 50)))));
AddAssert("selection not initiated", () => this.ChildrenOfType<DragBox>().All(d => d.State == Visibility.Hidden));
AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
AddAssert("slider placed correctly", () =>
{
var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
Assert.Multiple(() =>
{
Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f));
Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f));
Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2));
Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
// the final position may be slightly off from the mouse position when drawing, account for that.
Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5));
Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5));
});
return true;
});
}
private void tap(Drawable drawable) => tap(drawable.ScreenSpaceDrawQuad.Centre);
private void tap(Vector2 position)
{
InputManager.BeginTouch(new Touch(TouchSource.Touch1, position));
InputManager.EndTouch(new Touch(TouchSource.Touch1, position));
}
} }
} }

View File

@ -9,6 +9,7 @@ using osu.Framework.Utils;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Visual; using osu.Game.Tests.Visual;
using osu.Game.Utils; using osu.Game.Utils;
@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void gridActive<T>(bool active) where T : PositionSnapGrid 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("choose placement tool", () => InputManager.Key(Key.Number2));
AddStep("move cursor to spacing + (1, 1)", () => AddStep("move cursor to spacing + (1, 1)", () =>
{ {
@ -161,7 +163,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
return grid switch return grid switch
{ {
RectangularPositionSnapGrid rectangular => rectangular.StartPosition.Value + GeometryUtils.RotateVector(rectangular.Spacing.Value, -rectangular.GridLineRotation.Value), 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), CircularPositionSnapGrid circular => circular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(circular.Spacing.Value, 0), -45),
_ => Vector2.Zero _ => Vector2.Zero
}; };
@ -170,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Test] [Test]
public void TestGridSizeToggling() 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()); AddUntilStep("rectangular grid visible", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Any());
gridSizeIs(4); gridSizeIs(4);
@ -189,5 +192,97 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void gridSizeIs(int size) private void gridSizeIs(int size)
=> AddAssert($"grid size is {size}", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Single().Spacing.Value == new Vector2(size) => AddAssert($"grid size is {size}", () => this.ChildrenOfType<RectangularPositionSnapGrid>().Single().Spacing.Value == new Vector2(size)
&& EditorBeatmap.BeatmapInfo.GridSize == 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

@ -4,6 +4,7 @@
using System; using System;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Input;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
@ -392,6 +393,29 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertFinalControlPointType(3, null); assertFinalControlPointType(3, null);
} }
[Test]
public void TestSliderDrawingViaTouch()
{
Vector2 startPoint = new Vector2(200);
AddStep("move mouse to a random point", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(Vector2.Zero)));
AddStep("begin touch at start point", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(startPoint))));
for (int i = 1; i < 20; i++)
addTouchMovementStep(startPoint + new Vector2(i * 40, MathF.Sin(i * MathF.PI / 5) * 50));
AddStep("release touch at end point", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
assertPlaced(true);
assertLength(808, tolerance: 10);
assertControlPointCount(5);
assertFinalControlPointType(0, PathType.BSpline(4));
assertFinalControlPointType(1, null);
assertFinalControlPointType(2, null);
assertFinalControlPointType(3, null);
assertFinalControlPointType(4, null);
}
[Test] [Test]
public void TestPlacePerfectCurveSegmentAlmostLinearlyExterior() public void TestPlacePerfectCurveSegmentAlmostLinearlyExterior()
{ {
@ -492,6 +516,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position))); private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position)));
private void addTouchMovementStep(Vector2 position) => AddStep($"move touch1 to {position}", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(position))));
private void addClickStep(MouseButton button) private void addClickStep(MouseButton button)
{ {
AddStep($"click {button}", () => InputManager.Click(button)); AddStep($"click {button}", () => InputManager.Click(button));

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

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

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />

View File

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

View File

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

View File

@ -46,6 +46,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("slider_factor")] [JsonProperty("slider_factor")]
public double SliderFactor { get; set; } public double SliderFactor { get; set; }
[JsonProperty("aim_difficult_strain_count")]
public double AimDifficultStrainCount { get; set; }
[JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; }
/// <summary> /// <summary>
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary> /// </summary>
@ -99,6 +105,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor);
yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
} }
@ -113,8 +122,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY]; StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
DrainRate = onlineInfo.DrainRate; DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount; HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount; SliderCount = onlineInfo.SliderCount;

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{ {
private const double difficulty_multiplier = 0.0675; private const double difficulty_multiplier = 0.0675;
public override int Version => 20220902; public override int Version => 20241007;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
@ -48,6 +48,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountDifficultStrains();
double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountDifficultStrains();
if (mods.Any(m => m is OsuModTouchDevice)) if (mods.Any(m => m is OsuModTouchDevice))
{ {
aimRating = Math.Pow(aimRating, 0.8); aimRating = Math.Pow(aimRating, 0.8);
@ -100,6 +103,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedNoteCount = speedNotes, SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating, FlashlightDifficulty = flashlightRating,
SliderFactor = sliderFactor, SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultyStrainCount,
SpeedDifficultStrainCount = speedDifficultyStrainCount,
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6, OverallDifficulty = (80 - hitWindowGreat) / 6,
DrainRate = drainRate, DrainRate = drainRate,

View File

@ -14,7 +14,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuPerformanceCalculator : PerformanceCalculator public class OsuPerformanceCalculator : PerformanceCalculator
{ {
public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
private bool usingClassicSliderAccuracy;
private double accuracy; private double accuracy;
private int scoreMaxCombo; private int scoreMaxCombo;
@ -23,6 +25,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private int countMeh; private int countMeh;
private int countMiss; private int countMiss;
/// <summary>
/// Missed slider ticks that includes missed reverse arrows. Will only be correct on non-classic scores
/// </summary>
private int countSliderTickMiss;
/// <summary>
/// Amount of missed slider tails that don't break combo. Will only be correct on non-classic scores
/// </summary>
private int countSliderEndsDropped;
/// <summary>
/// Estimated total amount of combo breaks
/// </summary>
private double effectiveMissCount; private double effectiveMissCount;
public OsuPerformanceCalculator() public OsuPerformanceCalculator()
@ -34,13 +49,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{ {
var osuAttributes = (OsuDifficultyAttributes)attributes; var osuAttributes = (OsuDifficultyAttributes)attributes;
usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value);
accuracy = score.Accuracy; accuracy = score.Accuracy;
scoreMaxCombo = score.MaxCombo; scoreMaxCombo = score.MaxCombo;
countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
effectiveMissCount = calculateEffectiveMissCount(osuAttributes); countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit);
countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss);
if (osuAttributes.SliderCount > 0)
{
if (usingClassicSliderAccuracy)
{
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold)
effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// In classic scores there can't be more misses than a sum of all non-perfect judgements
effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits);
}
else
{
double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
if (scoreMaxCombo < fullComboThreshold)
effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// Combine regular misses with tick misses since tick misses break combo as well
effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss);
}
}
effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
double multiplier = PERFORMANCE_BASE_MULTIPLIER; double multiplier = PERFORMANCE_BASE_MULTIPLIER;
@ -93,11 +139,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
aimValue *= lengthBonus; aimValue *= lengthBonus;
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
aimValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), effectiveMissCount); aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount);
aimValue *= getComboScalingFactor(attributes);
double approachRateFactor = 0.0; double approachRateFactor = 0.0;
if (attributes.ApproachRate > 10.33) if (attributes.ApproachRate > 10.33)
@ -123,8 +166,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (attributes.SliderCount > 0) if (attributes.SliderCount > 0)
{ {
double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); double estimateImproperlyFollowedDifficultSliders;
double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + attributes.SliderFactor;
if (usingClassicSliderAccuracy)
{
// When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders
int maximumPossibleDroppedSliders = totalImperfectHits;
estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders);
}
else
{
// We add tick misses here since they too mean that the player didn't follow the slider properly
// We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly
estimateImproperlyFollowedDifficultSliders = Math.Min(countSliderEndsDropped + countSliderTickMiss, estimateDifficultSliders);
}
double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor;
aimValue *= sliderNerfFactor; aimValue *= sliderNerfFactor;
} }
@ -146,11 +203,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
speedValue *= lengthBonus; speedValue *= lengthBonus;
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
speedValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount);
speedValue *= getComboScalingFactor(attributes);
double approachRateFactor = 0.0; double approachRateFactor = 0.0;
if (attributes.ApproachRate > 10.33) if (attributes.ApproachRate > 10.33)
@ -177,7 +231,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD. // Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);
// Scale the speed value with # of 50s to punish doubletapping. // Scale the speed value with # of 50s to punish doubletapping.
speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
@ -193,6 +247,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
double betterAccuracyPercentage; double betterAccuracyPercentage;
int amountHitObjectsWithAccuracy = attributes.HitCircleCount; int amountHitObjectsWithAccuracy = attributes.HitCircleCount;
if (!usingClassicSliderAccuracy)
amountHitObjectsWithAccuracy += attributes.SliderCount;
if (amountHitObjectsWithAccuracy > 0) if (amountHitObjectsWithAccuracy > 0)
betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6);
@ -247,25 +303,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return flashlightValue; return flashlightValue;
} }
private double calculateEffectiveMissCount(OsuDifficultyAttributes attributes) // Miss penalty assumes that a player will miss on the hardest parts of a map,
{ // so we use the amount of relatively difficult sections to adjust miss penalty
// Guess the number of misses + slider breaks from combo // to make it more punishing on maps with lower amount of hard sections.
double comboBasedMissCount = 0.0; private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
if (attributes.SliderCount > 0)
{
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold)
comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
}
// Clamp miss count to maximum amount of possible breaks
comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss);
return Math.Max(countMiss, comboBasedMissCount);
}
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalImperfectHits => countOk + countMeh + countMiss;
} }
} }

View File

@ -20,7 +20,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary> /// </summary>
public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
private const int min_delta_time = 25; public const int MIN_DELTA_TIME = 25;
private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f; private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f;
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;
@ -93,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
this.lastObject = (OsuHitObject)lastObject; this.lastObject = (OsuHitObject)lastObject;
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
StrainTime = Math.Max(DeltaTime, min_delta_time); StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
if (BaseObject is Slider sliderObject) if (BaseObject is Slider sliderObject)
{ {
@ -136,6 +137,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0); return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0);
} }
/// <summary>
/// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1
/// </summary>
public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj)
{
if (osuNextObj != null)
{
double currDeltaTime = Math.Max(1, DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2);
return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
}
return 0;
}
private void setDistances(double clockRate) private void setDistances(double clockRate)
{ {
if (BaseObject is Slider currentSlider) if (BaseObject is Slider currentSlider)
@ -143,7 +162,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
computeSliderCursorPosition(currentSlider); computeSliderCursorPosition(currentSlider);
// Bonus for repeat sliders until a better per nested object strain system can be achieved. // Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
} }
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
@ -167,8 +186,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (lastObject is Slider lastSlider) if (lastObject is Slider lastSlider)
{ {
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time); MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME);
// //
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.

View File

@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain; private double currentStrain;
private double skillMultiplier => 24.963; private double skillMultiplier => 25.18;
private double strainDecayBase => 0.15; private double strainDecayBase => 0.15;
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{ {
currentStrain *= strainDecay(current.DeltaTime); currentStrain *= strainDecay(current.DeltaTime);
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier; currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier;
ObjectStrains.Add(currentStrain);
return currentStrain; return currentStrain;
} }

View File

@ -23,6 +23,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary> /// </summary>
protected virtual double ReducedStrainBaseline => 0.75; protected virtual double ReducedStrainBaseline => 0.75;
protected List<double> ObjectStrains = new List<double>();
protected double Difficulty;
protected OsuStrainSkill(Mod[] mods) protected OsuStrainSkill(Mod[] mods)
: base(mods) : base(mods)
{ {
@ -30,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
public override double DifficultyValue() public override double DifficultyValue()
{ {
double difficulty = 0; Difficulty = 0;
double weight = 1; double weight = 1;
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
@ -50,11 +53,25 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
// We're sorting from highest to lowest strain. // We're sorting from highest to lowest strain.
foreach (double strain in strains.OrderDescending()) foreach (double strain in strains.OrderDescending())
{ {
difficulty += strain * weight; Difficulty += strain * weight;
weight *= DecayWeight; weight *= DecayWeight;
} }
return difficulty; return Difficulty;
}
/// <summary>
/// Returns the number of strains weighted against the top strain.
/// The result is scaled by clock rate as it affects the total number of strains.
/// </summary>
public double CountDifficultStrains()
{
if (Difficulty == 0)
return 0.0;
double consistentTopStrain = Difficulty / 10; // What would the top strain be if all strain values were identical
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88))));
} }
public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0; public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0;

View File

@ -6,7 +6,6 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@ -24,8 +23,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override int ReducedSectionCount => 5; protected override int ReducedSectionCount => 5;
private readonly List<double> objectStrains = new List<double>();
public Speed(Mod[] mods) public Speed(Mod[] mods)
: base(mods) : base(mods)
{ {
@ -43,23 +40,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm; double totalStrain = currentStrain * currentRhythm;
ObjectStrains.Add(totalStrain);
objectStrains.Add(totalStrain);
return totalStrain; return totalStrain;
} }
public double RelevantNoteCount() public double RelevantNoteCount()
{ {
if (objectStrains.Count == 0) if (ObjectStrains.Count == 0)
return 0; return 0;
double maxStrain = objectStrains.Max(); double maxStrain = ObjectStrains.Max();
if (maxStrain == 0) if (maxStrain == 0)
return 0; return 0;
return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
} }
} }
} }

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

@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (segment.Count == 0) if (segment.Count == 0)
return; return;
var first = segment[0]; PathControlPoint first = segment[0];
if (first.Type != PathType.PERFECT_CURVE) if (first.Type != PathType.PERFECT_CURVE)
return; return;
@ -178,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
private bool isSplittable(PathControlPointPiece<T> p) => private bool isSplittable(PathControlPointPiece<T> p) =>
// A hit object can only be split on control points which connect two different path segments. // 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) private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e)
{ {
@ -273,10 +273,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (selectedPieces.Length != 1) if (selectedPieces.Length != 1)
return false; return false;
var selectedPiece = selectedPieces.Single(); PathControlPointPiece<T> selectedPiece = selectedPieces.Single();
var selectedPoint = selectedPiece.ControlPoint; PathControlPoint selectedPoint = selectedPiece.ControlPoint;
var validTypes = path_types; PathType?[] validTypes = path_types;
if (selectedPoint == controlPoints[0]) if (selectedPoint == controlPoints[0])
validTypes = validTypes.Where(t => t != null).ToArray(); validTypes = validTypes.Where(t => t != null).ToArray();
@ -313,7 +313,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (Pieces.All(p => !p.IsSelected.Value)) if (Pieces.All(p => !p.IsSelected.Value))
return false; return false;
var type = path_types[e.Key - Key.Number1]; PathType? type = path_types[e.Key - Key.Number1];
// The first control point can never be inherit type // The first control point can never be inherit type
if (Pieces[0].IsSelected.Value && type == null) if (Pieces[0].IsSelected.Value && type == null)
@ -357,7 +357,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
foreach (var p in Pieces.Where(p => p.IsSelected.Value)) foreach (var p in Pieces.Where(p => p.IsSelected.Value))
{ {
var pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint); List<PathControlPoint> pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint); int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint);
if (type?.Type == SplineType.PerfectCurve) if (type?.Type == SplineType.PerfectCurve)
@ -412,14 +412,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public void DragInProgress(DragEvent e) public void DragInProgress(DragEvent e)
{ {
Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray(); Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray();
var oldPosition = hitObject.Position; Vector2 oldPosition = hitObject.Position;
double oldStartTime = hitObject.StartTime; double oldStartTime = hitObject.StartTime;
if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0])) if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0]))
{ {
// Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account // Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account
Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex]));
var result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position; Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position;
@ -428,7 +428,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++) for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++)
{ {
var controlPoint = hitObject.Path.ControlPoints[i]; PathControlPoint controlPoint = hitObject.Path.ControlPoints[i];
// Since control points are relative to the position of the hit object, all points that are _not_ selected // Since control points are relative to the position of the hit object, all points that are _not_ selected
// need to be offset _back_ by the delta corresponding to the movement of the head point. // need to be offset _back_ by the delta corresponding to the movement of the head point.
// All other selected control points (if any) will move together with the head point // All other selected control points (if any) will move together with the head point
@ -439,13 +439,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
} }
else else
{ {
var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
for (int i = 0; i < controlPoints.Count; ++i) for (int i = 0; i < controlPoints.Count; ++i)
{ {
var controlPoint = controlPoints[i]; PathControlPoint controlPoint = controlPoints[i];
if (selectedControlPoints.Contains(controlPoint)) if (selectedControlPoints.Contains(controlPoint))
controlPoint.Position = dragStartPositions[i] + movementDelta; controlPoint.Position = dragStartPositions[i] + movementDelta;
} }
@ -495,8 +495,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
curveTypeItems = new List<MenuItem>(); curveTypeItems = new List<MenuItem>();
foreach (PathType? type in path_types) for (int i = 0; i < path_types.Length; ++i)
{ {
PathType? type = path_types[i];
// special inherit case // special inherit case
if (type == null) if (type == null)
{ {
@ -506,7 +508,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
curveTypeItems.Add(new OsuMenuItemSpacer()); curveTypeItems.Add(new OsuMenuItemSpacer());
} }
curveTypeItems.Add(createMenuItemForPathType(type)); curveTypeItems.Add(createMenuItemForPathType(type, InputKey.Number1 + i));
} }
if (selectedPieces.Any(piece => piece.ControlPoint.Type?.Type == SplineType.Catmull)) if (selectedPieces.Any(piece => piece.ControlPoint.Type?.Type == SplineType.Catmull))
@ -540,7 +542,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
return menuItems.ToArray(); return menuItems.ToArray();
CurveTypeMenuItem createMenuItemForPathType(PathType? type) => new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type)); CurveTypeMenuItem createMenuItemForPathType(PathType? type, InputKey? key = null)
{
Hotkey hotkey = default;
if (key != null)
hotkey = new Hotkey(new KeyCombination(InputKey.Alt, key.Value));
return new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type)) { Hotkey = hotkey };
}
} }
} }

View File

@ -11,6 +11,7 @@ using osu.Framework.Caching;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Audio; using osu.Game.Audio;
@ -593,8 +594,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
changeHandler?.BeginChange(); changeHandler?.BeginChange();
addControlPoint(lastRightClickPosition); addControlPoint(lastRightClickPosition);
changeHandler?.EndChange(); changeHandler?.EndChange();
}), })
new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream), {
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft))
},
new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream)
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.Shift, InputKey.F))
},
}; };
// Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions. // Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions.

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

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -37,7 +38,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
MinValue = 0f, MinValue = 0f,
MaxValue = OsuPlayfield.BASE_SIZE.X, MaxValue = OsuPlayfield.BASE_SIZE.X,
Precision = 1f
}; };
/// <summary> /// <summary>
@ -47,7 +47,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
MinValue = 0f, MinValue = 0f,
MaxValue = OsuPlayfield.BASE_SIZE.Y, MaxValue = OsuPlayfield.BASE_SIZE.Y,
Precision = 1f
}; };
/// <summary> /// <summary>
@ -57,7 +56,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
MinValue = 4f, MinValue = 4f,
MaxValue = 128f, MaxValue = 128f,
Precision = 1f
}; };
/// <summary> /// <summary>
@ -67,14 +65,13 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
MinValue = -180f, MinValue = -180f,
MaxValue = 180f, MaxValue = 180f,
Precision = 1f
}; };
/// <summary> /// <summary>
/// Read-only bindable representing the grid's origin. /// Read-only bindable representing the grid's origin.
/// Equivalent to <code>new Vector2(StartPositionX, StartPositionY)</code> /// Equivalent to <code>new Vector2(StartPositionX, StartPositionY)</code>
/// </summary> /// </summary>
public Bindable<Vector2> StartPosition { get; } = new Bindable<Vector2>(); public Bindable<Vector2> StartPosition { get; } = new Bindable<Vector2>(OsuPlayfield.BASE_SIZE / 2);
/// <summary> /// <summary>
/// Read-only bindable representing the grid's spacing in both the X and Y dimension. /// Read-only bindable representing the grid's spacing in both the X and Y dimension.
@ -97,6 +94,26 @@ namespace osu.Game.Rulesets.Osu.Edit
private const float max_automatic_spacing = 64; 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] [BackgroundDependencyLoader]
private void load() private void load()
{ {
@ -160,22 +177,28 @@ namespace osu.Game.Rulesets.Osu.Edit
StartPositionX.BindValueChanged(x => StartPositionX.BindValueChanged(x =>
{ {
startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:N0}"; startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:#,0.##}";
startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:N0}"; startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:#,0.##}";
StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y); StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y);
}, true); }, true);
StartPositionY.BindValueChanged(y => StartPositionY.BindValueChanged(y =>
{ {
startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:N0}"; startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:#,0.##}";
startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:N0}"; startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:#,0.##}";
StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue); StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue);
}, true); }, true);
StartPosition.BindValueChanged(pos =>
{
StartPositionX.Value = pos.NewValue.X;
StartPositionY.Value = pos.NewValue.Y;
});
Spacing.BindValueChanged(spacing => Spacing.BindValueChanged(spacing =>
{ {
spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:N0}"; spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:#,0.##}";
spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:N0}"; spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:#,0.##}";
SpacingVector.Value = new Vector2(spacing.NewValue); SpacingVector.Value = new Vector2(spacing.NewValue);
editorBeatmap.BeatmapInfo.GridSize = (int)spacing.NewValue; editorBeatmap.BeatmapInfo.GridSize = (int)spacing.NewValue;
}, true); }, true);
@ -186,44 +209,50 @@ namespace osu.Game.Rulesets.Osu.Edit
gridLinesRotationSlider.ExpandedLabelText = $"Rotation: {rotation.NewValue:#,0.##}"; gridLinesRotationSlider.ExpandedLabelText = $"Rotation: {rotation.NewValue:#,0.##}";
}, true); }, true);
expandingContainer?.Expanded.BindValueChanged(v =>
{
gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint);
gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None;
}, true);
GridType.BindValueChanged(v => GridType.BindValueChanged(v =>
{ {
GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle; GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle;
gridTypeButtons.Items[(int)v.NewValue].Select();
switch (v.NewValue) switch (v.NewValue)
{ {
case PositionSnapGridType.Square: case PositionSnapGridType.Square:
GridLinesRotation.Value = ((GridLinesRotation.Value + 405) % 90) - 45; GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 90);
GridLinesRotation.MinValue = -45; GridLinesRotation.MinValue = -45;
GridLinesRotation.MaxValue = 45; GridLinesRotation.MaxValue = 45;
break; break;
case PositionSnapGridType.Triangle: case PositionSnapGridType.Triangle:
GridLinesRotation.Value = ((GridLinesRotation.Value + 390) % 60) - 30; GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 60);
GridLinesRotation.MinValue = -30; GridLinesRotation.MinValue = -30;
GridLinesRotation.MaxValue = 30; GridLinesRotation.MaxValue = 30;
break; break;
} }
}, true); }, true);
expandingContainer?.Expanded.BindValueChanged(v =>
{
gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint);
gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None;
}, true);
} }
private void nextGridSize() private float normalizeRotation(float rotation, float period)
{ {
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) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{ {
switch (e.Action) switch (e.Action)
{ {
case GlobalAction.EditorCycleGridDisplayMode: case GlobalAction.EditorCycleGridSpacing:
nextGridSize(); 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; return true;
} }

View File

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

View File

@ -240,39 +240,74 @@ namespace osu.Game.Rulesets.Osu.Edit
points = originalConvexHull!; points = originalConvexHull!;
foreach (var point in points) foreach (var point in points)
{ scale = clampToBounds(scale, point, Vector2.Zero, OsuPlayfield.BASE_SIZE);
scale = clampToBound(scale, point, Vector2.Zero);
scale = clampToBound(scale, point, 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); // 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)
Vector2 clampToBound(Vector2 s, Vector2 p, Vector2 bound)
{ {
p -= actualOrigin; 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 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); 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) switch (adjustAxis)
{ {
case Axes.X: 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; break;
case Axes.Y: 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; break;
case Axes.Both: 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; break;
} }
return s; 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() 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 }); scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue });
xCheckBox.Current.BindValueChanged(x => setAxis(x.NewValue, yCheckBox.Current.Value)); xCheckBox.Current.BindValueChanged(_ =>
yCheckBox.Current.BindValueChanged(y => setAxis(xCheckBox.Current.Value, y.NewValue)); {
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); selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled; playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
@ -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() private void updateAxisCheckBoxesEnabled()
{ {
if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre) if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
@ -175,12 +199,14 @@ namespace osu.Game.Rulesets.Osu.Edit
axisBindable.Disabled = !available; axisBindable.Disabled = !available;
} }
private void updateMaxScale() private void updateMinMaxScale()
{ {
if (!scaleHandler.OriginalSurroundingQuad.HasValue) if (!scaleHandler.OriginalSurroundingQuad.HasValue)
return; return;
const float min_scale = 0.5f;
const float max_scale = 10; const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value)); var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis) if (!scaleInfo.Value.XAxis)
@ -189,12 +215,21 @@ namespace osu.Game.Rulesets.Osu.Edit
scale.Y = max_scale; scale.Y = max_scale;
scaleInputBindable.MaxValue = MathF.Max(1, MathF.Min(scale.X, scale.Y)); 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) private void setOrigin(ScaleOrigin origin)
{ {
scaleInfo.Value = scaleInfo.Value with { Origin = origin }; scaleInfo.Value = scaleInfo.Value with { Origin = origin };
updateMaxScale(); updateMinMaxScale();
updateAxisCheckBoxesEnabled(); 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 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() protected override void PopIn()
{ {
base.PopIn(); base.PopIn();
scaleHandler.Begin(); scaleHandler.Begin();
updateMaxScale(); updateMinMaxScale();
} }
protected override void PopOut() protected override void PopOut()

View File

@ -16,13 +16,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{ {
public partial class OsuDifficultySection : SetupSection public partial class OsuDifficultySection : SetupSection
{ {
private LabelledSliderBar<float> circleSizeSlider { get; set; } = null!; private FormSliderBar<float> circleSizeSlider { get; set; } = null!;
private LabelledSliderBar<float> healthDrainSlider { get; set; } = null!; private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private LabelledSliderBar<float> approachRateSlider { get; set; } = null!; private FormSliderBar<float> approachRateSlider { get; set; } = null!;
private LabelledSliderBar<float> overallDifficultySlider { get; set; } = null!; private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private LabelledSliderBar<double> baseVelocitySlider { get; set; } = null!; private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private LabelledSliderBar<double> tickRateSlider { get; set; } = null!; private FormSliderBar<double> tickRateSlider { get; set; } = null!;
private LabelledSliderBar<float> stackLeniency { get; set; } = null!; private FormSliderBar<float> stackLeniency { get; set; } = null!;
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
@ -31,103 +31,110 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{ {
Children = new Drawable[] Children = new Drawable[]
{ {
circleSizeSlider = new LabelledSliderBar<float> circleSizeSlider = new FormSliderBar<float>
{ {
Label = BeatmapsetsStrings.ShowStatsCs, Caption = BeatmapsetsStrings.ShowStatsCs,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.CircleSizeDescription,
Description = EditorSetupStrings.CircleSizeDescription,
Current = new BindableFloat(Beatmap.Difficulty.CircleSize) Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
{ {
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
Precision = 0.1f, Precision = 0.1f,
}
}, },
healthDrainSlider = new LabelledSliderBar<float> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
healthDrainSlider = new FormSliderBar<float>
{ {
Label = BeatmapsetsStrings.ShowStatsDrain, Caption = BeatmapsetsStrings.ShowStatsDrain,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.DrainRateDescription,
Description = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate) Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{ {
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
Precision = 0.1f, Precision = 0.1f,
}
}, },
approachRateSlider = new LabelledSliderBar<float> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
approachRateSlider = new FormSliderBar<float>
{ {
Label = BeatmapsetsStrings.ShowStatsAr, Caption = BeatmapsetsStrings.ShowStatsAr,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.ApproachRateDescription,
Description = EditorSetupStrings.ApproachRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) Current = new BindableFloat(Beatmap.Difficulty.ApproachRate)
{ {
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
Precision = 0.1f, Precision = 0.1f,
}
}, },
overallDifficultySlider = new LabelledSliderBar<float> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
overallDifficultySlider = new FormSliderBar<float>
{ {
Label = BeatmapsetsStrings.ShowStatsAccuracy, Caption = BeatmapsetsStrings.ShowStatsAccuracy,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.OverallDifficultyDescription,
Description = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{ {
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
Precision = 0.1f, Precision = 0.1f,
}
}, },
baseVelocitySlider = new LabelledSliderBar<double> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
baseVelocitySlider = new FormSliderBar<double>
{ {
Label = EditorSetupStrings.BaseVelocity, Caption = EditorSetupStrings.BaseVelocity,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.BaseVelocityDescription,
Description = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{ {
Default = 1.4, Default = 1.4,
MinValue = 0.4, MinValue = 0.4,
MaxValue = 3.6, MaxValue = 3.6,
Precision = 0.01f, Precision = 0.01f,
}
}, },
tickRateSlider = new LabelledSliderBar<double> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
tickRateSlider = new FormSliderBar<double>
{ {
Label = EditorSetupStrings.TickRate, Caption = EditorSetupStrings.TickRate,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.TickRateDescription,
Description = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{ {
Default = 1, Default = 1,
MinValue = 1, MinValue = 1,
MaxValue = 4, MaxValue = 4,
Precision = 1, Precision = 1,
}
}, },
stackLeniency = new LabelledSliderBar<float> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
stackLeniency = new FormSliderBar<float>
{ {
Label = "Stack Leniency", Caption = "Stack Leniency",
FixedLabelWidth = LABEL_WIDTH, HintText = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.",
Description = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.",
Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency) Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency)
{ {
Default = 0.7f, Default = 0.7f,
MinValue = 0, MinValue = 0,
MaxValue = 1, MaxValue = 1,
Precision = 0.1f Precision = 0.1f
} },
TransferValueOnCommit = true,
TabbableContentContainer = this,
}, },
}; };
foreach (var item in Children.OfType<LabelledSliderBar<float>>()) foreach (var item in Children.OfType<FormSliderBar<float>>())
item.Current.ValueChanged += _ => updateValues(); item.Current.ValueChanged += _ => updateValues();
foreach (var item in Children.OfType<LabelledSliderBar<double>>()) foreach (var item in Children.OfType<FormSliderBar<double>>())
item.Current.ValueChanged += _ => updateValues(); item.Current.ValueChanged += _ => updateValues();
} }

View File

@ -2,10 +2,13 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Diagnostics;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Osu.Mods namespace osu.Game.Rulesets.Osu.Mods
{ {
@ -25,5 +28,14 @@ namespace osu.Game.Rulesets.Osu.Mods
} }
}; };
} }
public override void Update(Playfield playfield)
{
base.Update(playfield);
OsuPlayfield osuPlayfield = (OsuPlayfield)playfield;
Debug.Assert(osuPlayfield.Cursor != null);
osuPlayfield.Cursor.ActiveCursor.Rotation = -CurrentRotation;
}
} }
} }

View File

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

View File

@ -204,6 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects
SpanStartTime = e.SpanStartTime, SpanStartTime = e.SpanStartTime,
StartTime = e.Time, StartTime = e.Time,
Position = Position + Path.PositionAt(e.PathProgress), Position = Position + Path.PositionAt(e.PathProgress),
PathProgress = e.PathProgress,
StackHeight = StackHeight, StackHeight = StackHeight,
}); });
break; break;
@ -236,6 +237,7 @@ namespace osu.Game.Rulesets.Osu.Objects
StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration,
Position = Position + Path.PositionAt(e.PathProgress), Position = Position + Path.PositionAt(e.PathProgress),
StackHeight = StackHeight, StackHeight = StackHeight,
PathProgress = e.PathProgress,
}); });
break; break;
} }
@ -248,14 +250,27 @@ namespace osu.Game.Rulesets.Osu.Objects
{ {
endPositionCache.Invalidate(); endPositionCache.Invalidate();
if (HeadCircle != null) foreach (var nested in NestedHitObjects)
HeadCircle.Position = Position; {
switch (nested)
{
case SliderHeadCircle headCircle:
headCircle.Position = Position;
break;
if (TailCircle != null) case SliderTailCircle tailCircle:
TailCircle.Position = EndPosition; tailCircle.Position = EndPosition;
break;
if (LastRepeat != null) case SliderRepeat repeat:
LastRepeat.Position = RepeatCount % 2 == 0 ? Position : Position + Path.PositionAt(1); repeat.Position = Position + Path.PositionAt(repeat.PathProgress);
break;
case SliderTick tick:
tick.Position = Position + Path.PositionAt(tick.PathProgress);
break;
}
}
} }
protected void UpdateNestedSamples() protected void UpdateNestedSamples()

View File

@ -5,6 +5,8 @@ namespace osu.Game.Rulesets.Osu.Objects
{ {
public class SliderRepeat : SliderEndCircle public class SliderRepeat : SliderEndCircle
{ {
public double PathProgress { get; set; }
public SliderRepeat(Slider slider) public SliderRepeat(Slider slider)
: base(slider) : base(slider)
{ {

View File

@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Objects
{ {
public int SpanIndex { get; set; } public int SpanIndex { get; set; }
public double SpanStartTime { get; set; } public double SpanStartTime { get; set; }
public double PathProgress { get; set; }
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
{ {

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Localisation; using osu.Framework.Localisation;
@ -39,6 +40,7 @@ using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Ranking.Statistics; using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Osu namespace osu.Game.Rulesets.Osu
{ {
@ -336,10 +338,28 @@ namespace osu.Game.Rulesets.Osu
}; };
} }
public override IEnumerable<SetupSection> CreateEditorSetupSections() => public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[ [
new MetadataSection(),
new OsuDifficultySection(), new OsuDifficultySection(),
new ColoursSection(), new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(SetupScreen.SPACING),
Children = new Drawable[]
{
new ResourcesSection
{
RelativeSizeAxes = Axes.X,
},
new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
}
},
new DesignSection(),
]; ];
/// <seealso cref="OsuHitObject.ApplyDefaultsToSelf"/> /// <seealso cref="OsuHitObject.ApplyDefaultsToSelf"/>

View File

@ -3,7 +3,6 @@
using System; using System;
using System.Linq; using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Beatmaps;
@ -117,10 +116,9 @@ namespace osu.Game.Rulesets.Osu.Utils
if (osuObject is not Slider slider) if (osuObject is not Slider slider)
return; 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); static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y);
modifySlider(slider, reflectNestedObject, reflectControlPoint); modifySlider(slider, reflectControlPoint);
} }
/// <summary> /// <summary>
@ -134,10 +132,9 @@ namespace osu.Game.Rulesets.Osu.Utils
if (osuObject is not Slider slider) if (osuObject is not Slider slider)
return; 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); static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(point.Position.X, -point.Position.Y);
modifySlider(slider, reflectNestedObject, reflectControlPoint); modifySlider(slider, reflectControlPoint);
} }
/// <summary> /// <summary>
@ -146,10 +143,9 @@ namespace osu.Game.Rulesets.Osu.Utils
/// <param name="slider">The slider to be flipped.</param> /// <param name="slider">The slider to be flipped.</param>
public static void FlipSliderInPlaceHorizontally(Slider slider) 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); static void flipControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y);
modifySlider(slider, flipNestedObject, flipControlPoint); modifySlider(slider, flipControlPoint);
} }
/// <summary> /// <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> /// <param name="rotation">The angle, measured in radians, to rotate the slider by.</param>
public static void RotateSlider(Slider slider, float rotation) 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); 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(); var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray();
foreach (var point in controlPoints) foreach (var point in controlPoints)
modifyControlPoint(point); modifyControlPoint(point);

View File

@ -232,8 +232,6 @@ namespace osu.Game.Rulesets.Osu.Utils
slider.Position = workingObject.PositionModified = new Vector2(newX, newY); slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
workingObject.EndPositionModified = slider.EndPosition; workingObject.EndPositionModified = slider.EndPosition;
shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal);
return workingObject.PositionModified - previousPosition; return workingObject.PositionModified - previousPosition;
} }
@ -307,22 +305,6 @@ namespace osu.Game.Rulesets.Osu.Utils
return new RectangleF(left, top, right - left, bottom - top); return new RectangleF(left, top, right - left, bottom - top);
} }
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private static void shiftNestedObjects(Slider slider, Vector2 shift)
{
foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
{
if (!(hitObject is OsuHitObject osuHitObject))
continue;
osuHitObject.Position += shift;
}
}
/// <summary> /// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges. /// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary> /// </summary>
@ -431,7 +413,6 @@ namespace osu.Game.Rulesets.Osu.Utils
private class WorkingObject private class WorkingObject
{ {
public float RotationOriginal { get; } public float RotationOriginal { get; }
public Vector2 PositionOriginal { get; }
public Vector2 PositionModified { get; set; } public Vector2 PositionModified { get; set; }
public Vector2 EndPositionModified { get; set; } public Vector2 EndPositionModified { get; set; }
@ -442,7 +423,7 @@ namespace osu.Game.Rulesets.Osu.Utils
{ {
PositionInfo = positionInfo; PositionInfo = positionInfo;
RotationOriginal = HitObject is Slider slider ? getSliderRotation(slider) : 0; RotationOriginal = HitObject is Slider slider ? getSliderRotation(slider) : 0;
PositionModified = PositionOriginal = HitObject.Position; PositionModified = HitObject.Position;
EndPositionModified = HitObject.EndPosition; EndPositionModified = HitObject.EndPosition;
} }
} }

View File

@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Tests.Visual;
using osuTK.Input;
namespace osu.Game.Rulesets.Taiko.Tests.Editor
{
public partial class TestSceneEditorPlacement : EditorTestScene
{
protected override Ruleset CreateEditorRuleset() => new TaikoRuleset();
[Test]
public void TestPlacementBlueprintDoesNotCauseCrashes()
{
AddStep("clear objects", () => EditorBeatmap.Clear());
AddStep("add two objects", () =>
{
EditorBeatmap.Add(new Hit { StartTime = 1818 });
EditorBeatmap.Add(new Hit { StartTime = 1584 });
});
AddStep("seek back", () => EditorClock.Seek(1584));
AddStep("choose hit placement tool", () => InputManager.Key(Key.Number2));
AddStep("hover over first hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType<DrawableHit>().ElementAt(1)));
AddStep("hover over second hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType<DrawableHit>().ElementAt(0)));
AddStep("right click", () => InputManager.Click(MouseButton.Right));
AddUntilStep("context menu open", () => Editor.ChildrenOfType<OsuContextMenu>().Any(menu => menu.State == MenuState.Open));
}
}
}

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -43,6 +43,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("great_hit_window")] [JsonProperty("great_hit_window")]
public double GreatHitWindow { get; set; } public double GreatHitWindow { get; set; }
/// <summary>
/// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("ok_hit_window")]
public double OkHitWindow { get; set; }
public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{ {
foreach (var v in base.ToDatabaseAttributes()) foreach (var v in base.ToDatabaseAttributes())
@ -50,6 +59,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@ -58,6 +68,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY]; StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
} }
} }
} }

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 colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_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) public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
@ -99,6 +99,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
ColourDifficulty = colourRating, ColourDifficulty = colourRating,
PeakDifficulty = combinedRating, PeakDifficulty = combinedRating,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
}; };

View File

@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("effective_miss_count")] [JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; } public double EffectiveMissCount { get; set; }
[JsonProperty("estimated_unstable_rate")]
public double? EstimatedUnstableRate { get; set; }
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay() public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
{ {
foreach (var attribute in base.GetAttributesForDisplay()) foreach (var attribute in base.GetAttributesForDisplay())

View File

@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Taiko.Difficulty namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private int countOk; private int countOk;
private int countMeh; private int countMeh;
private int countMiss; private int countMiss;
private double accuracy; private double? estimatedUnstableRate;
private double effectiveMissCount; private double effectiveMissCount;
@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
accuracy = customAccuracy; estimatedUnstableRate = computeDeviationUpperBound(taikoAttributes) * 10;
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000.
if (totalSuccessfulHits > 0) if (totalSuccessfulHits > 0)
@ -65,6 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Difficulty = difficultyValue, Difficulty = difficultyValue,
Accuracy = accuracyValue, Accuracy = accuracyValue,
EffectiveMissCount = effectiveMissCount, EffectiveMissCount = effectiveMissCount,
EstimatedUnstableRate = estimatedUnstableRate,
Total = totalValue Total = totalValue
}; };
} }
@ -85,35 +87,94 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
difficultyValue *= 1.025; difficultyValue *= 1.025;
if (score.Mods.Any(m => m is ModHardRock)) if (score.Mods.Any(m => m is ModHardRock))
difficultyValue *= 1.050; difficultyValue *= 1.10;
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>)) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
difficultyValue *= 1.050 * lengthBonus; difficultyValue *= 1.050 * lengthBonus;
return difficultyValue * Math.Pow(accuracy, 2.0); if (estimatedUnstableRate == null)
return 0;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(400 / (Math.Sqrt(2) * estimatedUnstableRate.Value)), 2.0);
} }
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
{ {
if (attributes.GreatHitWindow <= 0) if (attributes.GreatHitWindow <= 0 || estimatedUnstableRate == null)
return 0; return 0;
double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0;
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
accuracyValue *= lengthBonus;
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values.
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert)
accuracyValue *= Math.Max(1.0, 1.1 * lengthBonus); accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus);
return accuracyValue; return accuracyValue;
} }
/// <summary>
/// Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders,
/// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that
/// two SS scores on the same map with the same settings will always return the same deviation.
/// </summary>
private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes)
{
if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0)
return null;
double h300 = attributes.GreatHitWindow;
double h100 = attributes.OkHitWindow;
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
// The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window.
double? calcDeviationGreatWindow()
{
if (countGreat == 0) return null;
double n = totalHits;
// Proportion of greats hit.
double p = countGreat / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// We can be 99% confident that the deviation is not higher than:
return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
// The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window.
// This will return a lower value than the first method when the number of 100s is high, but the miss count is low.
double? calcDeviationGoodWindow()
{
if (totalSuccessfulHits == 0) return null;
double n = totalHits;
// Proportion of greats + goods hit.
double p = totalSuccessfulHits / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// We can be 99% confident that the deviation is not higher than:
return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
double? deviationGreatWindow = calcDeviationGreatWindow();
double? deviationGoodWindow = calcDeviationGoodWindow();
if (deviationGreatWindow is null)
return deviationGoodWindow;
return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value);
}
private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh; private int totalSuccessfulHits => countGreat + countOk + countMeh;
private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0;
} }
} }

View File

@ -16,10 +16,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{ {
public partial class TaikoDifficultySection : SetupSection public partial class TaikoDifficultySection : SetupSection
{ {
private LabelledSliderBar<float> healthDrainSlider { get; set; } = null!; private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private LabelledSliderBar<float> overallDifficultySlider { get; set; } = null!; private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private LabelledSliderBar<double> baseVelocitySlider { get; set; } = null!; private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private LabelledSliderBar<double> tickRateSlider { get; set; } = null!; private FormSliderBar<double> tickRateSlider { get; set; } = null!;
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
@ -28,64 +28,68 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{ {
Children = new Drawable[] Children = new Drawable[]
{ {
healthDrainSlider = new LabelledSliderBar<float> healthDrainSlider = new FormSliderBar<float>
{ {
Label = BeatmapsetsStrings.ShowStatsDrain, Caption = BeatmapsetsStrings.ShowStatsDrain,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.DrainRateDescription,
Description = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate) Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{ {
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
Precision = 0.1f, Precision = 0.1f,
}
}, },
overallDifficultySlider = new LabelledSliderBar<float> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
overallDifficultySlider = new FormSliderBar<float>
{ {
Label = BeatmapsetsStrings.ShowStatsAccuracy, Caption = BeatmapsetsStrings.ShowStatsAccuracy,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.OverallDifficultyDescription,
Description = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{ {
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
Precision = 0.1f, Precision = 0.1f,
}
}, },
baseVelocitySlider = new LabelledSliderBar<double> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
baseVelocitySlider = new FormSliderBar<double>
{ {
Label = EditorSetupStrings.BaseVelocity, Caption = EditorSetupStrings.BaseVelocity,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.BaseVelocityDescription,
Description = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{ {
Default = 1.4, Default = 1.4,
MinValue = 0.4, MinValue = 0.4,
MaxValue = 3.6, MaxValue = 3.6,
Precision = 0.01f, Precision = 0.01f,
}
}, },
tickRateSlider = new LabelledSliderBar<double> TransferValueOnCommit = true,
TabbableContentContainer = this,
},
tickRateSlider = new FormSliderBar<double>
{ {
Label = EditorSetupStrings.TickRate, Caption = EditorSetupStrings.TickRate,
FixedLabelWidth = LABEL_WIDTH, HintText = EditorSetupStrings.TickRateDescription,
Description = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{ {
Default = 1, Default = 1,
MinValue = 1, MinValue = 1,
MaxValue = 4, MaxValue = 4,
Precision = 1, Precision = 1,
} },
TransferValueOnCommit = true,
TabbableContentContainer = this,
}, },
}; };
foreach (var item in Children.OfType<LabelledSliderBar<float>>()) foreach (var item in Children.OfType<FormSliderBar<float>>())
item.Current.ValueChanged += _ => updateValues(); item.Current.ValueChanged += _ => updateValues();
foreach (var item in Children.OfType<LabelledSliderBar<double>>()) foreach (var item in Children.OfType<FormSliderBar<double>>())
item.Current.ValueChanged += _ => updateValues(); item.Current.ValueChanged += _ => updateValues();
} }

View File

@ -6,6 +6,7 @@ using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -86,10 +87,22 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection) protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{ {
if (selection.All(s => s.Item is Hit)) if (selection.All(s => s.Item is Hit))
yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } }; {
yield return new TernaryStateToggleMenuItem("Rim")
{
State = { BindTarget = selectionRimState },
Hotkey = new Hotkey(new KeyCombination(InputKey.W), new KeyCombination(InputKey.R)),
};
}
if (selection.All(s => s.Item is TaikoHitObject)) if (selection.All(s => s.Item is TaikoHitObject))
yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; {
yield return new TernaryStateToggleMenuItem("Strong")
{
State = { BindTarget = selectionStrongState },
Hotkey = new Hotkey(new KeyCombination(InputKey.E)),
};
}
foreach (var item in base.GetContextMenuItemsForSelection(selection)) foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item; yield return item;

View File

@ -190,9 +190,12 @@ namespace osu.Game.Rulesets.Taiko
public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this); public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this);
public override IEnumerable<SetupSection> CreateEditorSetupSections() => public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[ [
new MetadataSection(),
new TaikoDifficultySection(), new TaikoDifficultySection(),
new ResourcesSection(),
new DesignSection(),
]; ];
public override IBeatmapVerifier CreateBeatmapVerifier() => new TaikoBeatmapVerifier(); public override IBeatmapVerifier CreateBeatmapVerifier() => new TaikoBeatmapVerifier();

View File

@ -22,9 +22,9 @@ namespace osu.Game.Tests.Database
[HeadlessTest] [HeadlessTest]
public partial class BackgroundDataStoreProcessorTests : OsuTestScene, ILocalUserPlayInfo 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!; private BeatmapSetInfo importedSet = null!;
@ -37,7 +37,7 @@ namespace osu.Game.Tests.Database
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public void SetUpSteps()
{ {
AddStep("Set not playing", () => isPlaying.Value = false); AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying);
} }
[Test] [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", () => 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", () => AddUntilStep("wait for difficulties repopulated", () =>
{ {

View File

@ -716,7 +716,7 @@ namespace osu.Game.Tests.Database
{ {
foreach (var entry in zip.Entries.ToArray()) foreach (var entry in zip.Entries.ToArray())
{ {
if (entry.Key.EndsWith(".osu", StringComparison.InvariantCulture)) if (entry.Key!.EndsWith(".osu", StringComparison.InvariantCulture))
zip.RemoveEntry(entry); zip.RemoveEntry(entry);
} }

View File

@ -267,23 +267,26 @@ namespace osu.Game.Tests.Editing
[Test] [Test]
public void TestUseCurrentSnap() public void TestUseCurrentSnap()
{ {
ExpandableButton getCurrentSnapButton() => composer.ChildrenOfType<EditorToolboxGroup>().Single(g => g.Name == "snapping")
.ChildrenOfType<ExpandableButton>().Single();
AddStep("add objects to beatmap", () => AddStep("add objects to beatmap", () =>
{ {
editorBeatmap.Add(new HitCircle { StartTime = 1000 }); editorBeatmap.Add(new HitCircle { StartTime = 1000 });
editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 }); editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 });
}); });
AddStep("hover use current snap button", () => InputManager.MoveMouseTo(composer.ChildrenOfType<ExpandableButton>().Single())); AddStep("hover use current snap button", () => InputManager.MoveMouseTo(getCurrentSnapButton()));
AddUntilStep("use current snap expanded", () => composer.ChildrenOfType<ExpandableButton>().Single().Expanded.Value, () => Is.True); AddUntilStep("use current snap expanded", () => getCurrentSnapButton().Expanded.Value, () => Is.True);
AddStep("seek before first object", () => EditorClock.Seek(0)); AddStep("seek before first object", () => EditorClock.Seek(0));
AddUntilStep("use current snap not available", () => composer.ChildrenOfType<ExpandableButton>().Single().Enabled.Value, () => Is.False); AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
AddStep("seek to between objects", () => EditorClock.Seek(1500)); AddStep("seek to between objects", () => EditorClock.Seek(1500));
AddUntilStep("use current snap available", () => composer.ChildrenOfType<ExpandableButton>().Single().Enabled.Value, () => Is.True); AddUntilStep("use current snap available", () => getCurrentSnapButton().Enabled.Value, () => Is.True);
AddStep("seek after last object", () => EditorClock.Seek(2500)); AddStep("seek after last object", () => EditorClock.Seek(2500));
AddUntilStep("use current snap not available", () => composer.ChildrenOfType<ExpandableButton>().Single().Enabled.Value, () => Is.False); AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
} }
private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity)

View File

@ -3,11 +3,13 @@
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Configuration; using osu.Framework.Configuration;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Input; using osu.Game.Input;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual; using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Input namespace osu.Game.Tests.Input
@ -15,9 +17,20 @@ namespace osu.Game.Tests.Input
[HeadlessTest] [HeadlessTest]
public partial class ConfineMouseTrackerTest : OsuGameTestScene public partial class ConfineMouseTrackerTest : OsuGameTestScene
{ {
private readonly Bindable<LocalUserPlayingState> playingState = new Bindable<LocalUserPlayingState>();
[Resolved] [Resolved]
private FrameworkConfigManager frameworkConfigManager { get; set; } = null!; 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.Windowed)]
[TestCase(WindowMode.Borderless)] [TestCase(WindowMode.Borderless)]
public void TestDisableConfining(WindowMode windowMode) 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)); => AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode));
private void setLocalUserPlayingTo(bool playing) 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) private void gameSideModeIs(OsuConfineMouseMode mode)
=> AddAssert($"mode is {mode} game-side", () => Game.LocalConfig.Get<OsuConfineMouseMode>(OsuSetting.ConfineMouseMode) == 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); 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 = private static readonly object[] played_query_tests =
{ {
new object[] { "0", DateTimeOffset.MinValue, true }, new object[] { "0", DateTimeOffset.MinValue, true },

View File

@ -6,10 +6,12 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Game.Localisation;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Metadata; using osu.Game.Online.Metadata;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Tests.Resources; using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Metadata; 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))); AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room)));
AddUntilStep("wait for screen", () => screen.IsCurrentScreen()); AddUntilStep("wait for screen", () => screen.IsCurrentScreen());
AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null); 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

@ -0,0 +1,123 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Editing
{
[HeadlessTest]
public partial class TestSceneColoursSection : OsuManualInputManagerTestScene
{
[Test]
public void TestNoBeatmapSkinColours()
{
LegacyBeatmapSkin skin = null!;
ColoursSection coloursSection = null!;
AddStep("create beatmap skin", () => skin = new LegacyBeatmapSkin(new BeatmapInfo(), null));
AddStep("create colours section", () => Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(EditorBeatmap), new EditorBeatmap(new Beatmap
{
BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }
}, skin)),
(typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
],
Child = coloursSection = new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
});
AddAssert("beatmap skin has no colours", () => skin.Configuration.CustomComboColours, () => Is.Empty);
AddAssert("section displays default combo colours",
() => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours,
() => Is.EquivalentTo(new Colour4[]
{
SkinConfiguration.DefaultComboColours[1],
SkinConfiguration.DefaultComboColours[2],
SkinConfiguration.DefaultComboColours[3],
SkinConfiguration.DefaultComboColours[0],
}));
AddStep("add a colour", () => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours.Add(Colour4.Aqua));
AddAssert("beatmap skin has colours",
() => skin.Configuration.CustomComboColours,
() => Is.EquivalentTo(new[]
{
SkinConfiguration.DefaultComboColours[1],
SkinConfiguration.DefaultComboColours[2],
SkinConfiguration.DefaultComboColours[3],
Color4.Aqua,
SkinConfiguration.DefaultComboColours[0],
}));
}
[Test]
public void TestExistingColours()
{
LegacyBeatmapSkin skin = null!;
ColoursSection coloursSection = null!;
AddStep("create beatmap skin", () =>
{
skin = new LegacyBeatmapSkin(new BeatmapInfo(), null);
skin.Configuration.CustomComboColours = new List<Color4>
{
Color4.Azure,
Color4.Beige,
Color4.Chartreuse
};
});
AddStep("create colours section", () => Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(EditorBeatmap), new EditorBeatmap(new Beatmap
{
BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }
}, skin)),
(typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
],
Child = coloursSection = new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
});
AddAssert("section displays combo colours",
() => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours,
() => Is.EquivalentTo(new[]
{
Colour4.Beige,
Colour4.Chartreuse,
Colour4.Azure,
}));
AddStep("add a colour", () => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours.Add(Colour4.Aqua));
AddAssert("beatmap skin has colours",
() => skin.Configuration.CustomComboColours,
() => Is.EquivalentTo(new[]
{
Color4.Azure,
Color4.Beige,
Color4.Aqua,
Color4.Chartreuse
}));
}
}
}

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -82,7 +83,7 @@ namespace osu.Game.Tests.Visual.Editing
} }
[Test] [Test]
public void TestNudgeSelection() public void TestNudgeSelectionTime()
{ {
HitCircle[] addedObjects = null!; HitCircle[] addedObjects = null!;
@ -103,6 +104,51 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100);
} }
[Test]
public void TestNudgeSelectionPosition()
{
HitCircle addedObject = null!;
AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[]
{
addedObject = new HitCircle { StartTime = 200, Position = new Vector2(100) },
}));
AddStep("select object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("nudge up", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Up);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved up", () => addedObject.Position.Y, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON));
AddStep("nudge down", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Down);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved down", () => addedObject.Position.Y, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON));
AddStep("nudge left", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved left", () => addedObject.Position.X, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON));
AddStep("nudge right", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Right);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved right", () => addedObject.Position.X, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON));
}
[Test] [Test]
public void TestRotateHotkeys() public void TestRotateHotkeys()
{ {

View File

@ -7,12 +7,14 @@ using System;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Setup;
@ -25,6 +27,9 @@ namespace osu.Game.Tests.Visual.Editing
private TestDesignSection designSection; private TestDesignSection designSection;
private EditorBeatmap editorBeatmap { get; set; } private EditorBeatmap editorBeatmap { get; set; }
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[SetUpSteps] [SetUpSteps]
public void SetUp() public void SetUp()
{ {
@ -42,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing
{ {
(typeof(EditorBeatmap), editorBeatmap) (typeof(EditorBeatmap), editorBeatmap)
}, },
Child = designSection = new TestDesignSection() Child = designSection = new TestDesignSection { RelativeSizeAxes = Axes.X }
}); });
} }
@ -99,11 +104,11 @@ namespace osu.Game.Tests.Visual.Editing
private partial class TestDesignSection : DesignSection private partial class TestDesignSection : DesignSection
{ {
public new LabelledSwitchButton EnableCountdown => base.EnableCountdown; public new FormCheckBox EnableCountdown => base.EnableCountdown;
public new FillFlowContainer CountdownSettings => base.CountdownSettings; public new FillFlowContainer CountdownSettings => base.CountdownSettings;
public new LabelledEnumDropdown<CountdownType> CountdownSpeed => base.CountdownSpeed; public new FormEnumDropdown<CountdownType> CountdownSpeed => base.CountdownSpeed;
public new LabelledNumberBox CountdownOffset => base.CountdownOffset; public new FormTextBox CountdownOffset => base.CountdownOffset;
} }
} }
} }

View File

@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Storyboards; using osu.Game.Storyboards;
using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Beatmaps.IO;
using osuTK.Input; using osuTK.Input;
@ -60,7 +61,7 @@ namespace osu.Game.Tests.Visual.Editing
beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash; beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash;
}); });
AddStep("click File", () => this.ChildrenOfType<DrawableOsuMenuItem>().First().TriggerClick()); AddStep("click File", () => this.ChildrenOfType<EditorMenuBar.DrawableEditorBarMenuItem>().First().TriggerClick());
if (i == 11) if (i == 11)
{ {
@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Editing
EditorBeatmap.EndChange(); EditorBeatmap.EndChange();
}); });
AddStep("click File", () => this.ChildrenOfType<DrawableOsuMenuItem>().First().TriggerClick()); AddStep("click File", () => this.ChildrenOfType<EditorMenuBar.DrawableEditorBarMenuItem>().First().TriggerClick());
AddStep("click delete", () => getDeleteMenuItem().TriggerClick()); AddStep("click delete", () => getDeleteMenuItem().TriggerClick());
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null);

View File

@ -1,11 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osuTK.Input; using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing namespace osu.Game.Tests.Visual.Editing
@ -135,9 +137,42 @@ namespace osu.Game.Tests.Visual.Editing
pressAndCheckTime(Key.Up, 0); pressAndCheckTime(Key.Up, 0);
} }
private void pressAndCheckTime(Key key, double expectedTime) [Test]
public void TestSeekBetweenObjects()
{ {
AddStep($"press {key}", () => InputManager.Key(key)); AddStep("add objects", () =>
{
EditorBeatmap.Clear();
EditorBeatmap.AddRange(new[]
{
new HitCircle { StartTime = 1000, },
new HitCircle { StartTime = 2250, },
new HitCircle { StartTime = 3600, },
});
});
AddStep("seek to 0", () => EditorClock.Seek(0));
pressAndCheckTime(Key.Right, 1000, Key.ControlLeft);
pressAndCheckTime(Key.Right, 2250, Key.ControlLeft);
pressAndCheckTime(Key.Right, 3600, Key.ControlLeft);
pressAndCheckTime(Key.Right, 3600, Key.ControlLeft);
pressAndCheckTime(Key.Left, 2250, Key.ControlLeft);
pressAndCheckTime(Key.Left, 1000, Key.ControlLeft);
pressAndCheckTime(Key.Left, 1000, Key.ControlLeft);
}
private void pressAndCheckTime(Key key, double expectedTime, params Key[] modifiers)
{
AddStep($"press {key} with {(modifiers.Any() ? string.Join(',', modifiers) : "no modifiers")}", () =>
{
foreach (var modifier in modifiers)
InputManager.PressKey(modifier);
InputManager.Key(key);
foreach (var modifier in modifiers)
InputManager.ReleaseKey(modifier);
});
AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1)); AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1));
} }
} }

View File

@ -6,11 +6,13 @@
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Setup;
@ -20,6 +22,9 @@ namespace osu.Game.Tests.Visual.Editing
{ {
public partial class TestSceneMetadataSection : OsuManualInputManagerTestScene public partial class TestSceneMetadataSection : OsuManualInputManagerTestScene
{ {
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Cached] [Cached]
private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap
{ {
@ -201,7 +206,7 @@ namespace osu.Game.Tests.Visual.Editing
} }
private void createSection() private void createSection()
=> AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection()); => AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection { RelativeSizeAxes = Axes.X });
private void assertArtistMetadata(string expected) private void assertArtistMetadata(string expected)
=> AddAssert($"artist metadata is {expected}", () => editorBeatmap.Metadata.Artist, () => Is.EqualTo(expected)); => AddAssert($"artist metadata is {expected}", () => editorBeatmap.Metadata.Artist, () => Is.EqualTo(expected));
@ -226,11 +231,11 @@ namespace osu.Game.Tests.Visual.Editing
private partial class TestMetadataSection : MetadataSection private partial class TestMetadataSection : MetadataSection
{ {
public new LabelledTextBox ArtistTextBox => base.ArtistTextBox; public new FormTextBox ArtistTextBox => base.ArtistTextBox;
public new LabelledTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; public new FormTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox;
public new LabelledTextBox TitleTextBox => base.TitleTextBox; public new FormTextBox TitleTextBox => base.TitleTextBox;
public new LabelledTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox; public new FormTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox;
} }
} }
} }

View File

@ -100,6 +100,20 @@ namespace osu.Game.Tests.Visual.Editing
assertOnScreenAt(EditorScreenMode.Compose, 0); assertOnScreenAt(EditorScreenMode.Compose, 0);
} }
[Test]
public void TestUrlDecodingOfArgs()
{
setUpEditor(new OsuRuleset().RulesetInfo);
AddAssert("is osu! ruleset", () => editorBeatmap.BeatmapInfo.Ruleset.Equals(new OsuRuleset().RulesetInfo));
AddStep("jump to encoded link", () => Game.HandleLink("osu://edit/00:14:142%20(1)"));
AddUntilStep("wait for seek", () => editorClock.SeekingOrStopped.Value);
AddAssert("time is correct", () => editorClock.CurrentTime, () => Is.EqualTo(14_142));
AddAssert("selected object is correct", () => editorBeatmap.SelectedHitObjects.Single().StartTime, () => Is.EqualTo(14_142));
}
private void addStepClickLink(string timestamp, string step = "", bool waitForSeek = true) private void addStepClickLink(string timestamp, string step = "", bool waitForSeek = true)
{ {
AddStep($"{step} {timestamp}", () => AddStep($"{step} {timestamp}", () =>

View File

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

View File

@ -34,6 +34,8 @@ namespace osu.Game.Tests.Visual.Menus
{ {
Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!;
AddStep("disable shuffle", () => Game.MusicController.Shuffle.Value = false);
// ensure we have at least two beatmaps available to identify the direction the music controller navigated to. // 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); AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5);
@ -77,5 +79,114 @@ namespace osu.Game.Tests.Visual.Menus
trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next); trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next);
AddAssert("track actually changed", () => !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); 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))] [Cached(typeof(ILocalUserPlayInfo))]
private ILocalUserPlayInfo localUserInfo; 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(); private TextBox textBox => chatDisplay.ChildrenOfType<TextBox>().First();
public TestSceneGameplayChatDisplay() public TestSceneGameplayChatDisplay()
{ {
var mockLocalUserInfo = new Mock<ILocalUserPlayInfo>(); var mockLocalUserInfo = new Mock<ILocalUserPlayInfo>();
mockLocalUserInfo.SetupGet(i => i.IsPlaying).Returns(localUserPlaying); mockLocalUserInfo.SetupGet(i => i.PlayingState).Returns(playingState);
localUserInfo = mockLocalUserInfo.Object; localUserInfo = mockLocalUserInfo.Object;
} }
@ -124,6 +124,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused); AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused);
private void setLocalUserPlaying(bool playing) => 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

@ -165,11 +165,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for room join", () => RoomJoined); AddUntilStep("wait for room join", () => RoomJoined);
AddStep("join other user (ready)", () => AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID }));
{ AddUntilStep("wait for user populated", () => MultiplayerClient.ClientRoom!.Users.Single(u => u.UserID == PLAYER_1_ID).User, () => Is.Not.Null);
MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID }); AddStep("other user ready", () => MultiplayerClient.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready));
MultiplayerClient.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready);
});
ClickButtonWhenEnabled<MultiplayerSpectateButton>(); ClickButtonWhenEnabled<MultiplayerSpectateButton>();

View File

@ -648,6 +648,34 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("Info message displayed", () => channelManager.CurrentChannel.Value.Messages.Last(), () => Is.InstanceOf(typeof(InfoMessage))); AddUntilStep("Info message displayed", () => channelManager.CurrentChannel.Value.Messages.Last(), () => Is.InstanceOf(typeof(InfoMessage)));
} }
[Test]
public void TestFiltering()
{
AddStep("Show overlay", () => chatOverlay.Show());
joinTestChannel(1);
joinTestChannel(3);
joinTestChannel(5);
joinChannel(new Channel(new APIUser { Id = 2001, Username = "alice" }));
joinChannel(new Channel(new APIUser { Id = 2002, Username = "bob" }));
joinChannel(new Channel(new APIUser { Id = 2003, Username = "charley the plant" }));
AddStep("filter to \"c\"", () => chatOverlay.ChildrenOfType<SearchTextBox>().Single().Text = "c");
AddUntilStep("bob filtered out", () => chatOverlay.ChildrenOfType<ChannelListItem>().Count(i => i.Alpha > 0), () => Is.EqualTo(5));
AddStep("filter to \"channel\"", () => chatOverlay.ChildrenOfType<SearchTextBox>().Single().Text = "channel");
AddUntilStep("only public channels left", () => chatOverlay.ChildrenOfType<ChannelListItem>().Count(i => i.Alpha > 0), () => Is.EqualTo(3));
AddStep("commit textbox", () =>
{
chatOverlay.ChildrenOfType<SearchTextBox>().Single().TakeFocus();
Schedule(() => InputManager.PressKey(Key.Enter));
});
AddUntilStep("#channel-2 active", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo("#channel-2"));
AddStep("filter to \"channel-3\"", () => chatOverlay.ChildrenOfType<SearchTextBox>().Single().Text = "channel-3");
AddUntilStep("no channels left", () => chatOverlay.ChildrenOfType<ChannelListItem>().Count(i => i.Alpha > 0), () => Is.EqualTo(0));
}
private void joinTestChannel(int i) private void joinTestChannel(int i)
{ {
AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i]));

View File

@ -8,11 +8,13 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -90,6 +92,48 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("no best score displayed", () => scoresContainer.ChildrenOfType<DrawableTopScore>().Count() == 1); 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] [Test]
public void TestUserBest() public void TestUserBest()
{ {
@ -103,6 +147,18 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType<ScoreTableRowBackground>().Any()); AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType<ScoreTableRowBackground>().Any());
AddAssert("best score displayed", () => scoresContainer.ChildrenOfType<DrawableTopScore>().Count() == 2); AddAssert("best score displayed", () => scoresContainer.ChildrenOfType<DrawableTopScore>().Count() == 2);
AddStep("Load scores with personal best FC", () =>
{
var allScores = createScores();
allScores.UserScore = createUserBest();
allScores.UserScore.Score.Accuracy = 1;
scoresContainer.Beatmap.Value.MaxCombo = allScores.UserScore.Score.MaxCombo = 1337;
scoresContainer.Scores = allScores;
});
AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType<ScoreTableRowBackground>().Any());
AddAssert("best score displayed", () => scoresContainer.ChildrenOfType<DrawableTopScore>().Count() == 2);
AddStep("Load scores with personal best (null position)", () => AddStep("Load scores with personal best (null position)", () =>
{ {
var allScores = createScores(); var allScores = createScores();
@ -287,13 +343,17 @@ namespace osu.Game.Tests.Visual.Online
const int initial_great_count = 2000; const int initial_great_count = 2000;
const int initial_tick_count = 100; const int initial_tick_count = 100;
const int initial_slider_end_count = 500;
int greatCount = initial_great_count; int greatCount = initial_great_count;
int tickCount = initial_tick_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.Great, greatCount },
{ HitResult.LargeTickHit, tickCount }, { HitResult.LargeTickHit, tickCount },
@ -301,10 +361,19 @@ namespace osu.Game.Tests.Visual.Online
{ HitResult.Meh, RNG.Next(100) }, { HitResult.Meh, RNG.Next(100) },
{ HitResult.Miss, initial_great_count - greatCount }, { HitResult.Miss, initial_great_count - greatCount },
{ HitResult.LargeTickMiss, initial_tick_count - tickCount }, { 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; greatCount -= 100;
tickCount -= RNG.Next(1, 5); tickCount -= RNG.Next(1, 5);
sliderEndCount -= 20;
} }
return scores; return scores;

View File

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

View File

@ -0,0 +1,98 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Models;
using osu.Game.Rulesets.Osu;
using osu.Game.Skinning.Components;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Visual.UserInterface
{
public partial class TestSceneBeatmapAttributeText : OsuTestScene
{
private readonly BeatmapAttributeText text;
public TestSceneBeatmapAttributeText()
{
Child = text = new BeatmapAttributeText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
};
}
[SetUp]
public void Setup() => Schedule(() =>
{
Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo)
{
BeatmapInfo =
{
BPM = 100,
DifficultyName = "_Difficulty",
Status = BeatmapOnlineStatus.Loved,
Metadata =
{
Title = "_Title",
TitleUnicode = "_Title",
Artist = "_Artist",
ArtistUnicode = "_Artist",
Author = new RealmUser { Username = "_Creator" },
Source = "_Source",
},
Difficulty =
{
CircleSize = 1,
DrainRate = 2,
OverallDifficulty = 3,
ApproachRate = 4,
}
}
});
});
[TestCase(BeatmapAttribute.CircleSize, "Circle Size: 1.00")]
[TestCase(BeatmapAttribute.HPDrain, "HP Drain: 2.00")]
[TestCase(BeatmapAttribute.Accuracy, "Accuracy: 3.00")]
[TestCase(BeatmapAttribute.ApproachRate, "Approach Rate: 4.00")]
[TestCase(BeatmapAttribute.Title, "Title: _Title")]
[TestCase(BeatmapAttribute.Artist, "Artist: _Artist")]
[TestCase(BeatmapAttribute.Creator, "Creator: _Creator")]
[TestCase(BeatmapAttribute.DifficultyName, "Difficulty: _Difficulty")]
[TestCase(BeatmapAttribute.Source, "Source: _Source")]
[TestCase(BeatmapAttribute.RankedStatus, "Beatmap Status: Loved")]
public void TestAttributeDisplay(BeatmapAttribute attribute, string expectedText)
{
AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute);
AddAssert("check correct text", getText, () => Is.EqualTo(expectedText));
}
[Test]
public void TestChangeBeatmap()
{
AddStep("set title attribute", () => text.Attribute.Value = BeatmapAttribute.Title);
AddAssert("check initial title", getText, () => Is.EqualTo("Title: _Title"));
AddStep("change to beatmap with another title", () => Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo)
{
BeatmapInfo =
{
Metadata =
{
Title = "Another"
}
}
}));
AddAssert("check new title", getText, () => Is.EqualTo("Title: Another"));
}
private string getText() => text.ChildrenOfType<SpriteText>().Single().Text.ToString();
}
}

View File

@ -12,6 +12,7 @@ using osu.Framework.Localisation;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Comments; using osu.Game.Overlays.Comments;
using osuTK; using osuTK;
@ -28,9 +29,10 @@ namespace osu.Game.Tests.Visual.UserInterface
private TestCancellableCommentEditor cancellableCommentEditor = null!; private TestCancellableCommentEditor cancellableCommentEditor = null!;
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
[SetUp] [SetUpSteps]
public void SetUp() => Schedule(() => public void SetUpSteps()
Add(new FillFlowContainer {
AddStep("create content", () => Child = new FillFlowContainer
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
@ -43,7 +45,8 @@ namespace osu.Game.Tests.Visual.UserInterface
commentEditor = new TestCommentEditor(), commentEditor = new TestCommentEditor(),
cancellableCommentEditor = new TestCancellableCommentEditor() cancellableCommentEditor = new TestCancellableCommentEditor()
} }
})); });
}
[Test] [Test]
public void TestCommitViaKeyboard() public void TestCommitViaKeyboard()
@ -133,6 +136,34 @@ namespace osu.Game.Tests.Visual.UserInterface
assertLoggedInState(); assertLoggedInState();
} }
[Test]
public void TestCommentsDisabled()
{
AddStep("no reason for disable", () => commentEditor.CommentableMeta.Value = new CommentableMeta
{
CurrentUserAttributes = new CommentableMeta.CommentableCurrentUserAttributes(),
});
AddAssert("textbox enabled", () => commentEditor.ChildrenOfType<TextBox>().Single().ReadOnly, () => Is.False);
AddStep("specific reason for disable", () => commentEditor.CommentableMeta.Value = new CommentableMeta
{
CurrentUserAttributes = new CommentableMeta.CommentableCurrentUserAttributes
{
CanNewCommentReason = "This comment section is disabled. For reasons.",
}
});
AddAssert("textbox disabled", () => commentEditor.ChildrenOfType<TextBox>().Single().ReadOnly, () => Is.True);
AddStep("entire commentable meta missing", () => commentEditor.CommentableMeta.Value = null);
AddAssert("textbox enabled", () => commentEditor.ChildrenOfType<TextBox>().Single().ReadOnly, () => Is.False);
AddStep("current user attributes missing", () => commentEditor.CommentableMeta.Value = new CommentableMeta
{
CurrentUserAttributes = null,
});
AddAssert("textbox enabled", () => commentEditor.ChildrenOfType<TextBox>().Single().ReadOnly, () => Is.True);
}
[Test] [Test]
public void TestCancelAction() public void TestCancelAction()
{ {
@ -167,8 +198,7 @@ namespace osu.Game.Tests.Visual.UserInterface
protected override LocalisableString GetButtonText(bool isLoggedIn) => protected override LocalisableString GetButtonText(bool isLoggedIn) =>
isLoggedIn ? @"Commit" : "You're logged out!"; isLoggedIn ? @"Commit" : "You're logged out!";
protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => protected override LocalisableString GetPlaceholderText() => @"This text box is empty";
isLoggedIn ? @"This text box is empty" : "Still empty, but now you can't type in it.";
} }
private partial class TestCancellableCommentEditor : CancellableCommentEditor private partial class TestCancellableCommentEditor : CancellableCommentEditor
@ -189,7 +219,7 @@ namespace osu.Game.Tests.Visual.UserInterface
} }
protected override LocalisableString GetButtonText(bool isLoggedIn) => @"Save"; protected override LocalisableString GetButtonText(bool isLoggedIn) => @"Save";
protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => @"Multiline textboxes soon"; protected override LocalisableString GetPlaceholderText() => @"Multiline textboxes soon";
} }
} }
} }

View File

@ -72,7 +72,7 @@ namespace osu.Game.Tests.Visual.UserInterface
}, },
new FormSliderBar<float> new FormSliderBar<float>
{ {
Caption = "Instantaneous slider", Caption = "Slider",
Current = new BindableFloat Current = new BindableFloat
{ {
MinValue = 0, MinValue = 0,
@ -82,19 +82,6 @@ namespace osu.Game.Tests.Visual.UserInterface
}, },
TabbableContentContainer = this, TabbableContentContainer = this,
}, },
new FormSliderBar<float>
{
Caption = "Non-instantaneous slider",
Current = new BindableFloat
{
MinValue = 0,
MaxValue = 10,
Value = 5,
Precision = 0.1f,
},
Instantaneous = false,
TabbableContentContainer = this,
},
new FormEnumDropdown<CountdownType> new FormEnumDropdown<CountdownType>
{ {
Caption = EditorSetupStrings.EnableCountdown, Caption = EditorSetupStrings.EnableCountdown,
@ -105,6 +92,17 @@ namespace osu.Game.Tests.Visual.UserInterface
Caption = "Audio file", Caption = "Audio file",
PlaceholderText = "Select an audio file", PlaceholderText = "Select an audio file",
}, },
new FormColourPalette
{
Caption = "Combo colours",
Colours =
{
Colour4.Red,
Colour4.Green,
Colour4.Blue,
Colour4.Yellow,
}
},
}, },
}, },
} }

View File

@ -0,0 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Tests.Visual.UserInterface
{
public partial class TestSceneFormSliderBar : OsuTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Test]
public void TestTransferValueOnCommit()
{
OsuSpriteText text;
FormSliderBar<float> slider = null!;
AddStep("create content", () =>
{
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 0.5f,
Direction = FillDirection.Vertical,
Spacing = new Vector2(10),
Children = new Drawable[]
{
text = new OsuSpriteText(),
slider = new FormSliderBar<float>
{
Caption = "Slider",
Current = new BindableFloat
{
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
Default = 5f,
}
},
}
};
slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true);
});
AddToggleStep("toggle transfer value on commit", b =>
{
if (slider.IsNotNull())
slider.TransferValueOnCommit = b;
});
}
}
}

View File

@ -0,0 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
namespace osu.Game.Tests.Visual.UserInterface
{
public partial class TestSceneHotkeyDisplay : ThemeComparisonTestScene
{
protected override Drawable CreateContent() => new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Direction = FillDirection.Vertical,
Children = new[]
{
new HotkeyDisplay { Hotkey = new Hotkey(new KeyCombination(InputKey.MouseLeft)) },
new HotkeyDisplay { Hotkey = new Hotkey(GlobalAction.EditorDecreaseDistanceSpacing) },
new HotkeyDisplay { Hotkey = new Hotkey(PlatformAction.Save) },
}
};
}
}

View File

@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="DeepEqual" Version="4.2.1" /> <PackageReference Include="DeepEqual" Version="4.2.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" /> <PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />

View File

@ -4,7 +4,7 @@
<StartupObject>osu.Game.Tournament.Tests.TournamentTestRunner</StartupObject> <StartupObject>osu.Game.Tournament.Tests.TournamentTestRunner</StartupObject>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NUnit" Version="3.14.0" /> <PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -2,18 +2,16 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using osu.Framework.Graphics.Sprites;
using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Dialog;
using osu.Game.Tournament.Models; using osu.Game.Tournament.Models;
namespace osu.Game.Tournament.Screens.Editors.Components namespace osu.Game.Tournament.Screens.Editors.Components
{ {
public partial class DeleteRoundDialog : DangerousActionDialog public partial class DeleteRoundDialog : DeletionDialog
{ {
public DeleteRoundDialog(TournamentRound round, Action action) public DeleteRoundDialog(TournamentRound round, Action action)
{ {
HeaderText = round.Name.Value.Length > 0 ? $@"Delete round ""{round.Name.Value}""?" : @"Delete unnamed round?"; HeaderText = round.Name.Value.Length > 0 ? $@"Delete round ""{round.Name.Value}""?" : @"Delete unnamed round?";
Icon = FontAwesome.Solid.Trash;
DangerousAction = action; DangerousAction = action;
} }
} }

View File

@ -2,20 +2,18 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using osu.Framework.Graphics.Sprites;
using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Dialog;
using osu.Game.Tournament.Models; using osu.Game.Tournament.Models;
namespace osu.Game.Tournament.Screens.Editors.Components namespace osu.Game.Tournament.Screens.Editors.Components
{ {
public partial class DeleteTeamDialog : DangerousActionDialog public partial class DeleteTeamDialog : DeletionDialog
{ {
public DeleteTeamDialog(TournamentTeam team, Action action) public DeleteTeamDialog(TournamentTeam team, Action action)
{ {
HeaderText = team.FullName.Value.Length > 0 ? $@"Delete team ""{team.FullName.Value}""?" : HeaderText = team.FullName.Value.Length > 0 ? $@"Delete team ""{team.FullName.Value}""?" :
team.Acronym.Value.Length > 0 ? $@"Delete team ""{team.Acronym.Value}""?" : team.Acronym.Value.Length > 0 ? $@"Delete team ""{team.Acronym.Value}""?" :
@"Delete unnamed team?"; @"Delete unnamed team?";
Icon = FontAwesome.Solid.Trash;
DangerousAction = action; DangerousAction = action;
} }
} }

View File

@ -62,7 +62,7 @@ namespace osu.Game.Beatmaps
} }
[UsedImplicitly] [UsedImplicitly]
private BeatmapInfo() protected BeatmapInfo()
{ {
} }

View File

@ -285,7 +285,8 @@ namespace osu.Game.Beatmaps
/// </summary> /// </summary>
/// <param name="query">The query.</param> /// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns> /// <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> /// <summary>
/// A default representation of a WorkingBeatmap to use when no beatmap is available. /// 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) public void Delete(Expression<Func<BeatmapSetInfo, bool>>? filter = null, bool silent = false)
{ {
Realm.Run(r => Realm.Run(r =>

View File

@ -183,7 +183,14 @@ namespace osu.Game.Beatmaps
#region Beatmap #region Beatmap
public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; public virtual bool BeatmapLoaded
{
get
{
lock (beatmapFetchLock)
return beatmapLoadTask?.IsCompleted ?? false;
}
}
public IBeatmap Beatmap public IBeatmap Beatmap
{ {

View File

@ -8,7 +8,7 @@ using osu.Game.Overlays.Dialog;
namespace osu.Game.Collections namespace osu.Game.Collections
{ {
public partial class DeleteCollectionDialog : DangerousActionDialog public partial class DeleteCollectionDialog : DeletionDialog
{ {
public DeleteCollectionDialog(Live<BeatmapCollection> collection, Action deleteAction) public DeleteCollectionDialog(Live<BeatmapCollection> collection, Action deleteAction)
{ {

View File

@ -606,7 +606,7 @@ namespace osu.Game.Database
{ {
// Importantly, also sleep if high performance session is active. // 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. // 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..."); Logger.Log("Background processing sleeping due to active gameplay...");
Thread.Sleep(TimeToSleepDuringGameplay); Thread.Sleep(TimeToSleepDuringGameplay);

View File

@ -44,7 +44,7 @@ namespace osu.Game.Database
{ {
if (changes == null) 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. // 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. // 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. /// 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. /// 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 /// 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> /// </summary>
private const int schema_version = 42; private const int schema_version = 43;
/// <summary> /// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods. /// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -375,10 +376,6 @@ namespace osu.Game.Database
{ {
foreach (var beatmap in beatmapSet.Beatmaps) foreach (var beatmap in beatmapSet.Beatmaps)
{ {
// Cascade delete related scores, else they will have a null beatmap against the model's spec.
foreach (var score in beatmap.Scores)
realm.Remove(score);
realm.Remove(beatmap.Metadata); realm.Remove(beatmap.Metadata);
realm.Remove(beatmap); realm.Remove(beatmap);
} }
@ -568,7 +565,7 @@ namespace osu.Game.Database
lock (notificationsResetMap) lock (notificationsResetMap)
{ {
// Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing. // 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); return RegisterCustomSubscription(action);
@ -1192,6 +1189,21 @@ namespace osu.Game.Database
} }
break; 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"); Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms");

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