1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-30 20:32:55 +08:00

Merge branch 'ppy:master' into fixed-accuracy

This commit is contained in:
Fina 2024-11-08 17:32:43 -08:00 committed by GitHub
commit 1d3ba69c37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 2171 additions and 820 deletions

View File

@ -88,7 +88,7 @@ jobs:
# Attempt to upload results even if test fails. # Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- name: Upload Test Results - name: Upload Test Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
if: ${{ always() }} if: ${{ always() }}
with: with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}

View File

@ -107,6 +107,7 @@ jobs:
master-environment: master-environment:
name: Save master environment name: Save master environment
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }}
outputs: outputs:
HEAD: ${{ steps.get-head.outputs.HEAD }} HEAD: ${{ steps.get-head.outputs.HEAD }}
steps: steps:

View File

@ -8,30 +8,37 @@ on:
workflows: [ "Continuous Integration" ] workflows: [ "Continuous Integration" ]
types: types:
- completed - completed
permissions: {}
permissions:
contents: read
actions: read
checks: write
jobs: jobs:
annotate: annotate:
permissions:
checks: write # to create checks (dorny/test-reporter)
name: Annotate CI run with test results name: Annotate CI run with test results
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} if: ${{ github.event.workflow_run.conclusion != 'cancelled' }}
strategy:
fail-fast: false
matrix:
os:
- { prettyname: Windows }
- { prettyname: macOS }
- { prettyname: Linux }
threadingMode: ['SingleThread', 'MultiThreaded']
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ${{ github.event.workflow_run.repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Download results
uses: actions/download-artifact@v4
with:
pattern: osu-test-results-*
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Annotate CI run with test results - name: Annotate CI run with test results
uses: dorny/test-reporter@v1.8.0 uses: dorny/test-reporter@v1.8.0
with: with:
artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} name: Results
name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}})
path: "*.trx" path: "*.trx"
reporter: dotnet-trx reporter: dotnet-trx
list-suites: 'failed' list-suites: 'failed'

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.1009.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2024.1025.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

@ -5,7 +5,6 @@ using Android.Content.PM;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
namespace osu.Android namespace osu.Android
@ -28,7 +27,7 @@ namespace osu.Android
{ {
gameActivity.RunOnUiThread(() => gameActivity.RunOnUiThread(() =>
{ {
gameActivity.RequestedOrientation = userPlaying.NewValue != LocalUserPlayingState.NotPlaying ? ScreenOrientation.Locked : gameActivity.DefaultOrientation; gameActivity.RequestedOrientation = userPlaying.NewValue == LocalUserPlayingState.Playing ? ScreenOrientation.Locked : gameActivity.DefaultOrientation;
}); });
} }
} }

View File

@ -141,12 +141,12 @@ namespace osu.Desktop
// Make sure that this is a laptop. // Make sure that this is a laptop.
IntPtr[] gpus = new IntPtr[64]; IntPtr[] gpus = new IntPtr[64];
if (checkError(EnumPhysicalGPUs(gpus, out int gpuCount))) if (checkError(EnumPhysicalGPUs(gpus, out int gpuCount), nameof(EnumPhysicalGPUs)))
return false; return false;
for (int i = 0; i < gpuCount; i++) for (int i = 0; i < gpuCount; i++)
{ {
if (checkError(GetSystemType(gpus[i], out var type))) if (checkError(GetSystemType(gpus[i], out var type), nameof(GetSystemType)))
return false; return false;
if (type == NvSystemType.LAPTOP) if (type == NvSystemType.LAPTOP)
@ -182,7 +182,7 @@ namespace osu.Desktop
bool success = setSetting(NvSettingID.OGL_THREAD_CONTROL_ID, (uint)value); bool success = setSetting(NvSettingID.OGL_THREAD_CONTROL_ID, (uint)value);
Logger.Log(success ? $"Threaded optimizations set to \"{value}\"!" : "Threaded optimizations set failed!"); Logger.Log(success ? $"[NVAPI] Threaded optimizations set to \"{value}\"!" : "[NVAPI] Threaded optimizations set failed!");
} }
} }
@ -205,7 +205,7 @@ namespace osu.Desktop
uint numApps = profile.NumOfApps; uint numApps = profile.NumOfApps;
if (checkError(EnumApplications(sessionHandle, profileHandle, 0, ref numApps, applications))) if (checkError(EnumApplications(sessionHandle, profileHandle, 0, ref numApps, applications), nameof(EnumApplications)))
return false; return false;
for (uint i = 0; i < numApps; i++) for (uint i = 0; i < numApps; i++)
@ -236,10 +236,10 @@ namespace osu.Desktop
isApplicationSpecific = true; isApplicationSpecific = true;
if (checkError(FindApplicationByName(sessionHandle, osu_filename, out profileHandle, ref application))) if (checkError(FindApplicationByName(sessionHandle, osu_filename, out profileHandle, ref application), nameof(FindApplicationByName)))
{ {
isApplicationSpecific = false; isApplicationSpecific = false;
if (checkError(GetCurrentGlobalProfile(sessionHandle, out profileHandle))) if (checkError(GetCurrentGlobalProfile(sessionHandle, out profileHandle), nameof(GetCurrentGlobalProfile)))
return false; return false;
} }
@ -263,7 +263,7 @@ namespace osu.Desktop
newProfile.GPUSupport[0] = 1; newProfile.GPUSupport[0] = 1;
if (checkError(CreateProfile(sessionHandle, ref newProfile, out profileHandle))) if (checkError(CreateProfile(sessionHandle, ref newProfile, out profileHandle), nameof(CreateProfile)))
return false; return false;
return true; return true;
@ -284,7 +284,7 @@ namespace osu.Desktop
SettingID = settingId SettingID = settingId
}; };
if (checkError(GetSetting(sessionHandle, profileHandle, settingId, ref setting))) if (checkError(GetSetting(sessionHandle, profileHandle, settingId, ref setting), nameof(GetSetting)))
return false; return false;
return true; return true;
@ -313,7 +313,7 @@ namespace osu.Desktop
}; };
// Set the thread state // Set the thread state
if (checkError(SetSetting(sessionHandle, profileHandle, ref newSetting))) if (checkError(SetSetting(sessionHandle, profileHandle, ref newSetting), nameof(SetSetting)))
return false; return false;
// Get the profile (needed to check app count) // Get the profile (needed to check app count)
@ -321,7 +321,7 @@ namespace osu.Desktop
{ {
Version = NvProfile.Stride Version = NvProfile.Stride
}; };
if (checkError(GetProfileInfo(sessionHandle, profileHandle, ref profile))) if (checkError(GetProfileInfo(sessionHandle, profileHandle, ref profile), nameof(GetProfileInfo)))
return false; return false;
if (!containsApplication(profileHandle, profile, out application)) if (!containsApplication(profileHandle, profile, out application))
@ -332,12 +332,12 @@ namespace osu.Desktop
application.AppName = osu_filename; application.AppName = osu_filename;
application.UserFriendlyName = APPLICATION_NAME; application.UserFriendlyName = APPLICATION_NAME;
if (checkError(CreateApplication(sessionHandle, profileHandle, ref application))) if (checkError(CreateApplication(sessionHandle, profileHandle, ref application), nameof(CreateApplication)))
return false; return false;
} }
// Save! // Save!
return !checkError(SaveSettings(sessionHandle)); return !checkError(SaveSettings(sessionHandle), nameof(SaveSettings));
} }
/// <summary> /// <summary>
@ -346,20 +346,25 @@ namespace osu.Desktop
/// <returns>If the operation succeeded.</returns> /// <returns>If the operation succeeded.</returns>
private static bool createSession() private static bool createSession()
{ {
if (checkError(CreateSession(out sessionHandle))) if (checkError(CreateSession(out sessionHandle), nameof(CreateSession)))
return false; return false;
// Load settings into session // Load settings into session
if (checkError(LoadSettings(sessionHandle))) if (checkError(LoadSettings(sessionHandle), nameof(LoadSettings)))
return false; return false;
return true; return true;
} }
private static bool checkError(NvStatus status) private static bool checkError(NvStatus status, string caller)
{ {
Status = status; Status = status;
return status != NvStatus.OK;
bool hasError = status != NvStatus.OK;
if (hasError)
Logger.Log($"[NVAPI] {caller} call failed with status code {status}");
return hasError;
} }
static NVAPI() static NVAPI()

View File

@ -8,7 +8,6 @@ namespace osu.Game.Rulesets.Catch.Edit
{ {
public partial class CatchEditorPlayfield : CatchPlayfield public partial class CatchEditorPlayfield : CatchPlayfield
{ {
// TODO fixme: the size of the catcher is not changed when circle size is changed in setup screen.
public CatchEditorPlayfield(IBeatmapDifficultyInfo difficulty) public CatchEditorPlayfield(IBeatmapDifficultyInfo difficulty)
: base(difficulty) : base(difficulty)
{ {

View File

@ -2,16 +2,22 @@
// 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.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
namespace osu.Game.Rulesets.Catch.Edit namespace osu.Game.Rulesets.Catch.Edit
{ {
public partial class DrawableCatchEditorRuleset : DrawableCatchRuleset public partial class DrawableCatchEditorRuleset : DrawableCatchRuleset
{ {
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
public readonly BindableDouble TimeRangeMultiplier = new BindableDouble(1); public readonly BindableDouble TimeRangeMultiplier = new BindableDouble(1);
public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
@ -28,6 +34,30 @@ namespace osu.Game.Rulesets.Catch.Edit
TimeRange.Value = gamePlayTimeRange * TimeRangeMultiplier.Value * playfieldStretch; TimeRange.Value = gamePlayTimeRange * TimeRangeMultiplier.Value * playfieldStretch;
} }
protected override void LoadComplete()
{
base.LoadComplete();
editorBeatmap.BeatmapReprocessed += onBeatmapReprocessed;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (editorBeatmap.IsNotNull())
editorBeatmap.BeatmapReprocessed -= onBeatmapReprocessed;
}
private void onBeatmapReprocessed()
{
if (Playfield is CatchEditorPlayfield catchPlayfield)
{
catchPlayfield.Catcher.ApplyDifficulty(editorBeatmap.Difficulty);
catchPlayfield.CatcherArea.CatcherTrails.UpdateCatcherTrailsScale(catchPlayfield.Catcher.BodyScale);
}
}
protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty); protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty);
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchEditorPlayfieldAdjustmentContainer(); public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchEditorPlayfieldAdjustmentContainer();

View File

@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Objects
{ {
} }
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default) public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default)
=> new BananaHitSampleInfo(newVolume.GetOr(Volume)); => new BananaHitSampleInfo(newVolume.GetOr(Volume));
public bool Equals(BananaHitSampleInfo? other) public bool Equals(BananaHitSampleInfo? other)

View File

@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary> /// <summary>
/// Width of the area that can be used to attempt catches during gameplay. /// Width of the area that can be used to attempt catches during gameplay.
/// </summary> /// </summary>
public readonly float CatchWidth; public float CatchWidth { get; private set; }
private readonly SkinnableCatcher body; private readonly SkinnableCatcher body;
@ -142,10 +142,7 @@ namespace osu.Game.Rulesets.Catch.UI
Size = new Vector2(BASE_SIZE); Size = new Vector2(BASE_SIZE);
if (difficulty != null) ApplyDifficulty(difficulty);
Scale = calculateScale(difficulty);
CatchWidth = CalculateCatchWidth(Scale);
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
@ -312,6 +309,17 @@ namespace osu.Game.Rulesets.Catch.UI
} }
} }
/// <summary>
/// Set the scale and catch width.
/// </summary>
public void ApplyDifficulty(IBeatmapDifficultyInfo? difficulty)
{
if (difficulty != null)
Scale = calculateScale(difficulty);
CatchWidth = CalculateCatchWidth(Scale);
}
/// <summary> /// <summary>
/// Drop any fruit off the plate. /// Drop any fruit off the plate.
/// </summary> /// </summary>

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.UI
private readonly CatchComboDisplay comboDisplay; private readonly CatchComboDisplay comboDisplay;
private readonly CatcherTrailDisplay catcherTrails; public readonly CatcherTrailDisplay CatcherTrails;
private Catcher catcher = null!; private Catcher catcher = null!;
@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Catch.UI
Children = new Drawable[] Children = new Drawable[]
{ {
catcherContainer = new Container<Catcher> { RelativeSizeAxes = Axes.Both }, catcherContainer = new Container<Catcher> { RelativeSizeAxes = Axes.Both },
catcherTrails = new CatcherTrailDisplay(), CatcherTrails = new CatcherTrailDisplay(),
comboDisplay = new CatchComboDisplay comboDisplay = new CatchComboDisplay
{ {
RelativeSizeAxes = Axes.None, RelativeSizeAxes = Axes.None,
@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Catch.UI
{ {
const double trail_generation_interval = 16; const double trail_generation_interval = 16;
if (Time.Current - catcherTrails.LastDashTrailTime >= trail_generation_interval) if (Time.Current - CatcherTrails.LastDashTrailTime >= trail_generation_interval)
displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing); displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing);
} }
@ -170,6 +170,6 @@ namespace osu.Game.Rulesets.Catch.UI
} }
} }
private void displayCatcherTrail(CatcherTrailAnimation animation) => catcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation)); private void displayCatcherTrail(CatcherTrailAnimation animation) => CatcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation));
} }
} }

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -10,6 +11,7 @@ using osu.Framework.Graphics.Pooling;
using osu.Game.Rulesets.Catch.Skinning; using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Objects.Pooling; using osu.Game.Rulesets.Objects.Pooling;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI namespace osu.Game.Rulesets.Catch.UI
@ -55,6 +57,25 @@ namespace osu.Game.Rulesets.Catch.UI
}; };
} }
/// <summary>
/// Update the scale of all trails.
/// </summary>
/// <param name="scale">The new body scale of the Catcher</param>
public void UpdateCatcherTrailsScale(Vector2 scale)
{
var oldEntries = Entries.ToList();
Clear();
foreach (var oldEntry in oldEntries)
{
// use magnitude of the new scale while preserving the sign of the old one in the X direction.
// the end effect is preserving the direction in which the trail sprites face, which is important.
var targetScale = new Vector2(Math.Abs(scale.X) * Math.Sign(oldEntry.Scale.X), Math.Abs(scale.Y));
Add(new CatcherTrailEntry(oldEntry.LifetimeStart, oldEntry.CatcherState, oldEntry.Position, targetScale, oldEntry.Animation));
}
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();

View File

@ -180,7 +180,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
if (slider == null) return; if (slider == null) return;
sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70); sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70, editorAutoBank: false);
slider.Samples.Add(sample.With()); slider.Samples.Add(sample.With());
}); });

View File

@ -58,6 +58,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit); countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit);
countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss); countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss);
effectiveMissCount = countMiss;
accuracy = calculateAccuracy(countGreat, countOk, countMeh, countMiss, totalHits); accuracy = calculateAccuracy(countGreat, countOk, countMeh, countMiss, totalHits);
@ -88,6 +89,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
} }
effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
effectiveMissCount = Math.Min(totalHits, effectiveMissCount);
double multiplier = PERFORMANCE_BASE_MULTIPLIER; double multiplier = PERFORMANCE_BASE_MULTIPLIER;

View File

@ -53,6 +53,8 @@ namespace osu.Game.Rulesets.Osu.Edit
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
Child = new FillFlowContainer Child = new FillFlowContainer
{ {
Width = 220, Width = 220,

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
@ -25,13 +26,17 @@ namespace osu.Game.Rulesets.Osu.Edit
private readonly OsuGridToolboxGroup gridToolbox; private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.GridCentre)); private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, EditorOrigin.GridCentre));
private SliderWithTextBoxInput<float> angleInput = null!; private SliderWithTextBoxInput<float> angleInput = null!;
private EditorRadioButtonCollection rotationOrigin = null!; private EditorRadioButtonCollection rotationOrigin = null!;
private RadioButton gridCentreButton = null!;
private RadioButton playfieldCentreButton = null!;
private RadioButton selectionCentreButton = null!; private RadioButton selectionCentreButton = null!;
private Bindable<EditorOrigin> configRotationOrigin = null!;
public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox) public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox)
{ {
this.rotationHandler = rotationHandler; this.rotationHandler = rotationHandler;
@ -41,8 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(OsuConfigManager config)
{ {
configRotationOrigin = config.GetBindable<EditorOrigin>(OsuSetting.EditorRotationOrigin);
Child = new FillFlowContainer Child = new FillFlowContainer
{ {
Width = 220, Width = 220,
@ -66,14 +73,14 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Items = new[] Items = new[]
{ {
new RadioButton("Grid centre", gridCentreButton = new RadioButton("Grid centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre }, () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.GridCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }), () => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
new RadioButton("Playfield centre", playfieldCentreButton = new RadioButton("Playfield centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.PlayfieldCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }), () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
selectionCentreButton = new RadioButton("Selection centre", selectionCentreButton = new RadioButton("Selection centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre }, () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.SelectionCentre },
() => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare })
} }
} }
@ -95,13 +102,63 @@ namespace osu.Game.Rulesets.Osu.Edit
angleInput.SelectAll(); angleInput.SelectAll();
}); });
angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue }); angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });
rotationOrigin.Items.First().Select();
rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e => rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e =>
{ {
selectionCentreButton.Selected.Disabled = !e.NewValue; selectionCentreButton.Selected.Disabled = !e.NewValue;
}, true); }, true);
bool didSelect = false;
configRotationOrigin.BindValueChanged(val =>
{
switch (configRotationOrigin.Value)
{
case EditorOrigin.GridCentre:
if (!gridCentreButton.Selected.Disabled)
{
gridCentreButton.Select();
didSelect = true;
}
break;
case EditorOrigin.PlayfieldCentre:
if (!playfieldCentreButton.Selected.Disabled)
{
playfieldCentreButton.Select();
didSelect = true;
}
break;
case EditorOrigin.SelectionCentre:
if (!selectionCentreButton.Selected.Disabled)
{
selectionCentreButton.Select();
didSelect = true;
}
break;
}
}, true);
if (!didSelect)
rotationOrigin.Items.First(b => !b.Selected.Disabled).Select();
gridCentreButton.Selected.BindValueChanged(b =>
{
if (b.NewValue) configRotationOrigin.Value = EditorOrigin.GridCentre;
});
playfieldCentreButton.Selected.BindValueChanged(b =>
{
if (b.NewValue) configRotationOrigin.Value = EditorOrigin.PlayfieldCentre;
});
selectionCentreButton.Selected.BindValueChanged(b =>
{
if (b.NewValue) configRotationOrigin.Value = EditorOrigin.SelectionCentre;
});
rotationInfo.BindValueChanged(rotation => rotationInfo.BindValueChanged(rotation =>
{ {
rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue)); rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue));
@ -111,9 +168,9 @@ namespace osu.Game.Rulesets.Osu.Edit
private Vector2? getOriginPosition(PreciseRotationInfo rotation) => private Vector2? getOriginPosition(PreciseRotationInfo rotation) =>
rotation.Origin switch rotation.Origin switch
{ {
RotationOrigin.GridCentre => gridToolbox.StartPosition.Value, EditorOrigin.GridCentre => gridToolbox.StartPosition.Value,
RotationOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2, EditorOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
RotationOrigin.SelectionCentre => null, EditorOrigin.SelectionCentre => null,
_ => throw new ArgumentOutOfRangeException(nameof(rotation)) _ => throw new ArgumentOutOfRangeException(nameof(rotation))
}; };
@ -143,12 +200,5 @@ namespace osu.Game.Rulesets.Osu.Edit
} }
} }
public enum RotationOrigin public record PreciseRotationInfo(float Degrees, EditorOrigin Origin);
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}
public record PreciseRotationInfo(float Degrees, RotationOrigin Origin);
} }

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
@ -18,6 +19,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
@ -28,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private readonly OsuGridToolboxGroup gridToolbox; private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.GridCentre, true, true)); private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, EditorOrigin.GridCentre, true, true));
private SliderWithTextBoxInput<float> scaleInput = null!; private SliderWithTextBoxInput<float> scaleInput = null!;
private BindableNumber<float> scaleInputBindable = null!; private BindableNumber<float> scaleInputBindable = null!;
@ -41,6 +43,8 @@ namespace osu.Game.Rulesets.Osu.Edit
private OsuCheckbox xCheckBox = null!; private OsuCheckbox xCheckBox = null!;
private OsuCheckbox yCheckBox = null!; private OsuCheckbox yCheckBox = null!;
private Bindable<EditorOrigin> configScaleOrigin = null!;
private BindableList<HitObject> selectedItems { get; } = new BindableList<HitObject>(); private BindableList<HitObject> selectedItems { get; } = new BindableList<HitObject>();
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox) public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox)
@ -52,10 +56,12 @@ namespace osu.Game.Rulesets.Osu.Edit
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(EditorBeatmap editorBeatmap) private void load(EditorBeatmap editorBeatmap, OsuConfigManager config)
{ {
selectedItems.BindTo(editorBeatmap.SelectedHitObjects); selectedItems.BindTo(editorBeatmap.SelectedHitObjects);
configScaleOrigin = config.GetBindable<EditorOrigin>(OsuSetting.EditorScaleOrigin);
Child = new FillFlowContainer Child = new FillFlowContainer
{ {
Width = 220, Width = 220,
@ -67,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
Current = scaleInputBindable = new BindableNumber<float> Current = scaleInputBindable = new BindableNumber<float>
{ {
MinValue = 0.5f, MinValue = 0.05f,
MaxValue = 2, MaxValue = 2,
Precision = 0.001f, Precision = 0.001f,
Value = 1, Value = 1,
@ -82,13 +88,13 @@ namespace osu.Game.Rulesets.Osu.Edit
Items = new[] Items = new[]
{ {
gridCentreButton = new RadioButton("Grid centre", gridCentreButton = new RadioButton("Grid centre",
() => setOrigin(ScaleOrigin.GridCentre), () => setOrigin(EditorOrigin.GridCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }), () => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
playfieldCentreButton = new RadioButton("Playfield centre", playfieldCentreButton = new RadioButton("Playfield centre",
() => setOrigin(ScaleOrigin.PlayfieldCentre), () => setOrigin(EditorOrigin.PlayfieldCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }), () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
selectionCentreButton = new RadioButton("Selection centre", selectionCentreButton = new RadioButton("Selection centre",
() => setOrigin(ScaleOrigin.SelectionCentre), () => setOrigin(EditorOrigin.SelectionCentre),
() => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare })
} }
}, },
@ -165,8 +171,57 @@ namespace osu.Game.Rulesets.Osu.Edit
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled; playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled; gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled;
bool didSelect = false;
configScaleOrigin.BindValueChanged(val =>
{
switch (configScaleOrigin.Value)
{
case EditorOrigin.GridCentre:
if (!gridCentreButton.Selected.Disabled)
{
gridCentreButton.Select();
didSelect = true;
}
break;
case EditorOrigin.PlayfieldCentre:
if (!playfieldCentreButton.Selected.Disabled)
{
playfieldCentreButton.Select();
didSelect = true;
}
break;
case EditorOrigin.SelectionCentre:
if (!selectionCentreButton.Selected.Disabled)
{
selectionCentreButton.Select();
didSelect = true;
}
break;
}
}, true);
if (!didSelect)
scaleOrigin.Items.First(b => !b.Selected.Disabled).Select(); scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();
gridCentreButton.Selected.BindValueChanged(b =>
{
if (b.NewValue) configScaleOrigin.Value = EditorOrigin.GridCentre;
});
playfieldCentreButton.Selected.BindValueChanged(b =>
{
if (b.NewValue) configScaleOrigin.Value = EditorOrigin.PlayfieldCentre;
});
selectionCentreButton.Selected.BindValueChanged(b =>
{
if (b.NewValue) configScaleOrigin.Value = EditorOrigin.SelectionCentre;
});
scaleInfo.BindValueChanged(scale => scaleInfo.BindValueChanged(scale =>
{ {
var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale); var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale);
@ -182,7 +237,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void updateAxisCheckBoxesEnabled() private void updateAxisCheckBoxesEnabled()
{ {
if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre) if (scaleInfo.Value.Origin != EditorOrigin.SelectionCentre)
{ {
toggleAxisAvailable(xCheckBox.Current, true); toggleAxisAvailable(xCheckBox.Current, true);
toggleAxisAvailable(yCheckBox.Current, true); toggleAxisAvailable(yCheckBox.Current, true);
@ -208,7 +263,7 @@ namespace osu.Game.Rulesets.Osu.Edit
if (!scaleHandler.OriginalSurroundingQuad.HasValue) if (!scaleHandler.OriginalSurroundingQuad.HasValue)
return; return;
const float min_scale = 0.5f; const float min_scale = 0.05f;
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));
@ -230,7 +285,7 @@ namespace osu.Game.Rulesets.Osu.Edit
scaleInputBindable.MinValue = MathF.Min(1, MathF.Max(scale.X, scale.Y)); scaleInputBindable.MinValue = MathF.Min(1, MathF.Max(scale.X, scale.Y));
} }
private void setOrigin(ScaleOrigin origin) private void setOrigin(EditorOrigin origin)
{ {
scaleInfo.Value = scaleInfo.Value with { Origin = origin }; scaleInfo.Value = scaleInfo.Value with { Origin = origin };
updateMinMaxScale(); updateMinMaxScale();
@ -241,13 +296,13 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
switch (scale.Origin) switch (scale.Origin)
{ {
case ScaleOrigin.GridCentre: case EditorOrigin.GridCentre:
return gridToolbox.StartPosition.Value; return gridToolbox.StartPosition.Value;
case ScaleOrigin.PlayfieldCentre: case EditorOrigin.PlayfieldCentre:
return OsuPlayfield.BASE_SIZE / 2; return OsuPlayfield.BASE_SIZE / 2;
case ScaleOrigin.SelectionCentre: case EditorOrigin.SelectionCentre:
if (selectedItems.Count == 1 && selectedItems.First() is Slider slider) if (selectedItems.Count == 1 && selectedItems.First() is Slider slider)
return slider.Position; return slider.Position;
@ -271,7 +326,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return result; return result;
} }
private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0; private float getRotation(PreciseScaleInfo scale) => scale.Origin == EditorOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
protected override void PopIn() protected override void PopIn()
{ {
@ -299,12 +354,5 @@ namespace osu.Game.Rulesets.Osu.Edit
} }
} }
public enum ScaleOrigin public record PreciseScaleInfo(float Scale, EditorOrigin Origin, bool XAxis, bool YAxis);
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}
public record PreciseScaleInfo(float Scale, ScaleOrigin Origin, bool XAxis, bool YAxis);
} }

View File

@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
AddStep("load player", () => AddStep("load player", () =>
{ {
Beatmap.Value = CreateWorkingBeatmap(beatmap); Beatmap.Value = CreateWorkingBeatmap(beatmap);
Ruleset.Value = new TaikoRuleset().RulesetInfo;
SelectedMods.Value = mods ?? Array.Empty<Mod>(); SelectedMods.Value = mods ?? Array.Empty<Mod>();
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });

View File

@ -1,33 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{ {
/// <summary> /// <summary>
/// Calculates the stamina coefficient of taiko difficulty. /// Calculates the stamina coefficient of taiko difficulty.
/// </summary> /// </summary>
public class Stamina : StrainDecaySkill public class Stamina : StrainSkill
{ {
protected override double SkillMultiplier => 1.1; private double skillMultiplier => 1.1;
protected override double StrainDecayBase => 0.4; private double strainDecayBase => 0.4;
private readonly bool singleColourStamina;
private double currentStrain;
/// <summary> /// <summary>
/// Creates a <see cref="Stamina"/> skill. /// Creates a <see cref="Stamina"/> skill.
/// </summary> /// </summary>
/// <param name="mods">Mods for use in skill calculations.</param> /// <param name="mods">Mods for use in skill calculations.</param>
public Stamina(Mod[] mods) /// <param name="singleColourStamina">Reads when Stamina is from a single coloured pattern.</param>
public Stamina(Mod[] mods, bool singleColourStamina)
: base(mods) : base(mods)
{ {
this.singleColourStamina = singleColourStamina;
} }
protected override double StrainValueOf(DifficultyHitObject current) private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double StrainValueAt(DifficultyHitObject current)
{ {
return StaminaEvaluator.EvaluateDifficultyOf(current); currentStrain *= strainDecay(current.DeltaTime);
} currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
// Safely prevents previous strains from shifting as new notes are added.
var currentObject = current as TaikoDifficultyHitObject;
int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0;
if (singleColourStamina)
return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0));
return currentStrain;
}
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime);
} }
} }

View File

@ -16,6 +16,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("stamina_difficulty")] [JsonProperty("stamina_difficulty")]
public double StaminaDifficulty { get; set; } public double StaminaDifficulty { get; set; }
/// <summary>
/// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty.
/// </summary>
[JsonProperty("mono_stamina_factor")]
public double MonoStaminaFactor { get; set; }
/// <summary> /// <summary>
/// The difficulty corresponding to the rhythm skill. /// The difficulty corresponding to the rhythm skill.
/// </summary> /// </summary>
@ -60,6 +66,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); yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@ -69,6 +76,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]; OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR];
} }
} }
} }

View File

@ -38,7 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
new Rhythm(mods), new Rhythm(mods),
new Colour(mods), new Colour(mods),
new Stamina(mods) new Stamina(mods, false),
new Stamina(mods, true)
}; };
} }
@ -79,14 +80,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Colour colour = (Colour)skills.First(x => x is Colour); Colour colour = (Colour)skills.First(x => x is Colour);
Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm); Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm);
Stamina stamina = (Stamina)skills.First(x => x is Stamina); Stamina stamina = (Stamina)skills.First(x => x is Stamina);
Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina);
double colourRating = colour.DifficultyValue() * colour_skill_multiplier; double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
double combinedRating = combinedDifficultyValue(rhythm, colour, stamina); double combinedRating = combinedDifficultyValue(rhythm, colour, stamina);
double starRating = rescale(combinedRating * 1.4); double starRating = rescale(combinedRating * 1.4);
// TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
{
starRating *= 0.925;
// For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
if (colourRating < 2 && staminaRating > 8)
starRating *= 0.80;
}
HitWindows hitWindows = new TaikoHitWindows(); HitWindows hitWindows = new TaikoHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
@ -95,6 +108,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = starRating, StarRating = starRating,
Mods = mods, Mods = mods,
StaminaDifficulty = staminaRating, StaminaDifficulty = staminaRating,
MonoStaminaFactor = monoStaminaFactor,
RhythmDifficulty = rhythmRating, RhythmDifficulty = rhythmRating,
ColourDifficulty = colourRating, ColourDifficulty = colourRating,
PeakDifficulty = combinedRating, PeakDifficulty = combinedRating,

View File

@ -42,18 +42,18 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (totalSuccessfulHits > 0) if (totalSuccessfulHits > 0)
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
// TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation.
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
double multiplier = 1.13; double multiplier = 1.13;
if (score.Mods.Any(m => m is ModHidden)) if (score.Mods.Any(m => m is ModHidden) && !isConvert)
multiplier *= 1.075; multiplier *= 1.075;
if (score.Mods.Any(m => m is ModEasy)) if (score.Mods.Any(m => m is ModEasy))
multiplier *= 0.975; multiplier *= 0.950;
double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert); double difficultyValue = computeDifficultyValue(score, taikoAttributes);
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert);
double totalValue = double totalValue =
Math.Pow( Math.Pow(
@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
}; };
} }
private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
{ {
double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0;
@ -81,21 +81,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
difficultyValue *= Math.Pow(0.986, effectiveMissCount); difficultyValue *= Math.Pow(0.986, effectiveMissCount);
if (score.Mods.Any(m => m is ModEasy)) if (score.Mods.Any(m => m is ModEasy))
difficultyValue *= 0.985; difficultyValue *= 0.90;
if (score.Mods.Any(m => m is ModHidden) && !isConvert) if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025; difficultyValue *= 1.025;
if (score.Mods.Any(m => m is ModHardRock)) if (score.Mods.Any(m => m is ModHardRock))
difficultyValue *= 1.10; 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 *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
if (estimatedUnstableRate == null) if (estimatedUnstableRate == null)
return 0; return 0;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(400 / (Math.Sqrt(2) * estimatedUnstableRate.Value)), 2.0); // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
double accScalingExponent = 2 + attributes.MonoStaminaFactor;
double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent);
} }
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)

View File

@ -7,6 +7,7 @@ using osu.Game.Beatmaps;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.UI; using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Taiko.Edit namespace osu.Game.Rulesets.Taiko.Edit
@ -20,6 +21,8 @@ namespace osu.Game.Rulesets.Taiko.Edit
{ {
} }
protected override Playfield CreatePlayfield() => new TaikoEditorPlayfield();
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();

View File

@ -0,0 +1,25 @@
// 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.Graphics;
using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Edit
{
public partial class TaikoEditorPlayfield : TaikoPlayfield
{
[BackgroundDependencyLoader]
private void load()
{
// This is the simplest way to extend the taiko playfield beyond the left of the drum area.
// Required in the editor to not look weird underneath left toolbox area.
AddInternal(new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight())
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopRight,
});
}
}
}

View File

@ -31,6 +31,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad; public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad;
// done strictly for editor purposes.
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => MainPiece.Drawable.ReceivePositionalInputAt(screenSpacePos);
/// <summary> /// <summary>
/// Rolling number of tick hits. This increases for hits and decreases for misses. /// Rolling number of tick hits. This increases for hits and decreases for misses.
/// </summary> /// </summary>

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 osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -9,6 +10,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Rulesets.Taiko.Skinning.Legacy namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
@ -19,13 +21,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
{ {
get get
{ {
var headDrawQuad = headCircle.ScreenSpaceDrawQuad; // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii.
var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad; // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box.
var headCentre = headCircle.ScreenSpaceDrawQuad.Centre;
var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2;
return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight); float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2;
float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2;
float radius = Math.Max(headRadius, tailRadius);
var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius);
return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight);
} }
} }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos);
private LegacyCirclePiece headCircle = null!; private LegacyCirclePiece headCircle = null!;
private Sprite body = null!; private Sprite body = null!;

View File

@ -241,8 +241,8 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch); metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(beatmap.OnlineID, Is.EqualTo(-1)); Assert.That(beatmap.OnlineID, Is.EqualTo(654321));
} }
[Test] [Test]
@ -273,34 +273,6 @@ namespace osu.Game.Tests.Beatmaps
Assert.That(beatmap.OnlineID, Is.EqualTo(654321)); Assert.That(beatmap.OnlineID, Is.EqualTo(654321));
} }
[Test]
public void TestMetadataLookupForBeatmapWithoutPopulatedIDAndIncorrectHash([Values] bool preferOnlineFetch)
{
var lookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"cafebabe",
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out lookupResult))
.Returns(true);
var beatmap = new BeatmapInfo
{
MD5Hash = @"deadbeef"
};
var beatmapSet = new BeatmapSetInfo(beatmap.Yield());
beatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmap.OnlineID, Is.EqualTo(-1));
}
[Test] [Test]
public void TestReturnedMetadataHasDifferentHash([Values] bool preferOnlineFetch) public void TestReturnedMetadataHasDifferentHash([Values] bool preferOnlineFetch)
{ {
@ -383,58 +355,5 @@ namespace osu.Game.Tests.Beatmaps
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None)); Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
} }
[Test]
public void TestPartiallyMaliciousSet([Values] bool preferOnlineFetch)
{
var firstResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"cafebabe"
};
var secondResult = new OnlineBeatmapMetadata
{
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"dededede"
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.Is<BeatmapInfo>(bi => bi.OnlineID == 654321), out firstResult))
.Returns(true);
targetMock.Setup(src => src.TryLookup(It.Is<BeatmapInfo>(bi => bi.OnlineID == 666666), out secondResult))
.Returns(true);
var firstBeatmap = new BeatmapInfo
{
OnlineID = 654321,
MD5Hash = @"cafebabe",
};
var secondBeatmap = new BeatmapInfo
{
OnlineID = 666666,
MD5Hash = @"deadbeef"
};
var beatmapSet = new BeatmapSetInfo(new[]
{
firstBeatmap,
secondBeatmap
});
firstBeatmap.BeatmapSet = beatmapSet;
secondBeatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(firstBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(firstBeatmap.OnlineID, Is.EqualTo(654321));
Assert.That(secondBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(secondBeatmap.OnlineID, Is.EqualTo(-1));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
}
} }
} }

View File

@ -120,11 +120,11 @@ namespace osu.Game.Tests.Beatmaps.Formats
private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual) private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual)
{ {
// Check all control points that are still considered to be at a global level. // Check all control points that are still considered to be at a global level.
Assert.That(expected.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.TimingPoints.Serialize())); Assert.That(actual.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.TimingPoints.Serialize()));
Assert.That(expected.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.EffectPoints.Serialize())); Assert.That(actual.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.EffectPoints.Serialize()));
// Check all hitobjects. // Check all hitobjects.
Assert.That(expected.beatmap.HitObjects.Serialize(), Is.EqualTo(actual.beatmap.HitObjects.Serialize())); Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize()));
// Check skin. // Check skin.
Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration)); Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration));

View File

@ -0,0 +1,39 @@
osu file format v14
[General]
SampleSet: Normal
StackLeniency: 0.7
Mode: 3
[Difficulty]
HPDrainRate:3
CircleSize:5
OverallDifficulty:8
ApproachRate:8
SliderMultiplier:3.59999990463257
SliderTickRate:2
[TimingPoints]
24,352.941176470588,4,1,1,100,1,0
6376,-10000,4,1,1,100,0,0
[HitObjects]
51,192,24,1,0,0:0:0:0:
153,192,200,1,0,0:0:0:0:
358,192,376,1,0,0:0:0:0:
460,192,553,1,0,0:0:0:0:
460,192,729,128,0,1435:0:0:0:0:
358,192,906,128,0,1612:0:0:0:0:
256,192,1082,128,0,1788:0:0:0:0:
153,192,1259,128,0,1965:0:0:0:0:
51,192,1435,128,0,2141:0:0:0:0:
51,192,2318,1,12,0:0:0:0:
153,192,2318,1,4,0:0:0:0:
256,192,2318,1,6,0:0:0:0:
358,192,2318,1,14,0:0:0:0:
460,192,2318,1,0,0:0:0:0:
51,192,2494,128,0,2582:0:0:0:0:
153,192,2494,128,14,2582:0:0:0:0:
256,192,2494,128,6,2582:0:0:0:0:
358,192,2494,128,4,2582:0:0:0:0:
460,192,2494,128,12,2582:0:0:0:0:

View File

@ -155,7 +155,13 @@ namespace osu.Game.Tests.Visual.Gameplay
var api = (DummyAPIAccess)API; var api = (DummyAPIAccess)API;
api.Friends.Clear(); api.Friends.Clear();
api.Friends.Add(friend); api.Friends.Add(new APIRelation
{
Mutual = true,
RelationType = RelationType.Friend,
TargetID = friend.OnlineID,
TargetUser = friend
});
}); });
int playerNumber = 1; int playerNumber = 1;

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Linq; using System.Linq;
using Moq; using Moq;
@ -36,15 +34,18 @@ namespace osu.Game.Tests.Visual.Multiplayer
private readonly Bindable<BeatmapAvailability> beatmapAvailability = new Bindable<BeatmapAvailability>(); private readonly Bindable<BeatmapAvailability> beatmapAvailability = new Bindable<BeatmapAvailability>();
private readonly Bindable<Room> room = new Bindable<Room>(); private readonly Bindable<Room> room = new Bindable<Room>();
private MultiplayerRoom multiplayerRoom; private MultiplayerRoom multiplayerRoom = null!;
private MultiplayerRoomUser localUser; private MultiplayerRoomUser localUser = null!;
private OngoingOperationTracker ongoingOperationTracker; private OngoingOperationTracker ongoingOperationTracker = null!;
private PopoverContainer content; private PopoverContainer content = null!;
private MatchStartControl control; private MatchStartControl control = null!;
private OsuButton readyButton => control.ChildrenOfType<OsuButton>().Single(); private OsuButton readyButton => control.ChildrenOfType<OsuButton>().Single();
[Cached(typeof(IBindable<PlaylistItem>))]
private readonly Bindable<PlaylistItem> currentItem = new Bindable<PlaylistItem>();
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
new CachedModelDependencyContainer<Room>(base.CreateChildDependencies(parent)) { Model = { BindTarget = room } }; new CachedModelDependencyContainer<Room>(base.CreateChildDependencies(parent)) { Model = { BindTarget = room } };
@ -112,15 +113,15 @@ namespace osu.Game.Tests.Visual.Multiplayer
beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable(); beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable();
var playlistItem = new PlaylistItem(Beatmap.Value.BeatmapInfo) currentItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
{ {
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID
}; };
room.Value = new Room room.Value = new Room
{ {
Playlist = { playlistItem }, Playlist = { currentItem.Value },
CurrentPlaylistItem = { Value = playlistItem } CurrentPlaylistItem = { BindTarget = currentItem }
}; };
localUser = new MultiplayerRoomUser(API.LocalUser.Value.Id) { User = API.LocalUser.Value }; localUser = new MultiplayerRoomUser(API.LocalUser.Value.Id) { User = API.LocalUser.Value };
@ -129,7 +130,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
Playlist = Playlist =
{ {
TestMultiplayerClient.CreateMultiplayerPlaylistItem(playlistItem), TestMultiplayerClient.CreateMultiplayerPlaylistItem(currentItem.Value),
}, },
Users = { localUser }, Users = { localUser },
Host = localUser, Host = localUser,

View File

@ -1,15 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
namespace osu.Game.Tests.Visual.Multiplayer namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneMultiplayerMatchFooter : MultiplayerTestScene public partial class TestSceneMultiplayerMatchFooter : MultiplayerTestScene
{ {
[Cached(typeof(IBindable<PlaylistItem>))]
private readonly Bindable<PlaylistItem> currentItem = new Bindable<PlaylistItem>();
public override void SetUpSteps() public override void SetUpSteps()
{ {
base.SetUpSteps(); base.SetUpSteps();

View File

@ -1,13 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Platform; using osu.Framework.Platform;
@ -29,10 +28,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene
{ {
private MultiplayerPlaylist list; [Cached(typeof(IBindable<PlaylistItem>))]
private BeatmapManager beatmaps; private readonly Bindable<PlaylistItem> currentItem = new Bindable<PlaylistItem>();
private BeatmapSetInfo importedSet;
private BeatmapInfo importedBeatmap; private MultiplayerPlaylist list = null!;
private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!;
private BeatmapInfo importedBeatmap = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
@ -198,7 +200,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
addItemStep(); addItemStep();
addItemStep(); addItemStep();
DrawableRoomPlaylistItem[] drawableItems = null; DrawableRoomPlaylistItem[] drawableItems = null!;
AddStep("get drawable items", () => drawableItems = this.ChildrenOfType<DrawableRoomPlaylistItem>().ToArray()); AddStep("get drawable items", () => drawableItems = this.ChildrenOfType<DrawableRoomPlaylistItem>().ToArray());
// Add 1 item for another user. // Add 1 item for another user.

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.Framework.Allocation; using osu.Framework.Allocation;
@ -28,13 +26,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneMultiplayerSpectateButton : MultiplayerTestScene public partial class TestSceneMultiplayerSpectateButton : MultiplayerTestScene
{ {
private MultiplayerSpectateButton spectateButton; [Cached(typeof(IBindable<PlaylistItem>))]
private MatchStartControl startControl; private readonly Bindable<PlaylistItem> currentItem = new Bindable<PlaylistItem>();
private readonly Bindable<PlaylistItem> selectedItem = new Bindable<PlaylistItem>(); private MultiplayerSpectateButton spectateButton = null!;
private MatchStartControl startControl = null!;
private BeatmapSetInfo importedSet; private BeatmapSetInfo importedSet = null!;
private BeatmapManager beatmaps; private BeatmapManager beatmaps = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
@ -52,14 +51,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("create button", () => AddStep("create button", () =>
{ {
AvailabilityTracker.SelectedItem.BindTo(selectedItem); AvailabilityTracker.SelectedItem.BindTo(currentItem);
importedSet = beatmaps.GetAllUsableBeatmapSets().First(); importedSet = beatmaps.GetAllUsableBeatmapSets().First();
Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo)
{ currentItem.Value = SelectedRoom.Value.Playlist.First();
RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID,
};
Child = new PopoverContainer Child = new PopoverContainer
{ {

View File

@ -30,7 +30,12 @@ namespace osu.Game.Tests.Visual.Online
if (supportLevel > 3) if (supportLevel > 3)
supportLevel = 0; supportLevel = 0;
((DummyAPIAccess)API).Friends.Add(new APIUser ((DummyAPIAccess)API).Friends.Add(new APIRelation
{
TargetID = 2,
RelationType = RelationType.Friend,
Mutual = true,
TargetUser = new APIUser
{ {
Username = @"peppy", Username = @"peppy",
Id = 2, Id = 2,
@ -38,6 +43,7 @@ namespace osu.Game.Tests.Visual.Online
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
IsSupporter = supportLevel > 0, IsSupporter = supportLevel > 0,
SupportLevel = supportLevel SupportLevel = supportLevel
}
}); });
} }
} }

View File

@ -3,15 +3,20 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile;
using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Users; using osu.Game.Users;
@ -22,6 +27,10 @@ namespace osu.Game.Tests.Visual.Online
[Cached] [Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green);
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim();
[Resolved] [Resolved]
private OsuConfigManager configManager { get; set; } = null!; private OsuConfigManager configManager { get; set; } = null!;
@ -400,5 +409,97 @@ namespace osu.Game.Tests.Visual.Online
} }
}, new OsuRuleset().RulesetInfo)); }, new OsuRuleset().RulesetInfo));
} }
private APIUser nonFriend => new APIUser
{
Id = 727,
Username = "Whatever",
};
[Test]
public void TestAddFriend()
{
AddStep("Setup request", () =>
{
requestLock.Reset();
dummyAPI.HandleRequest = request =>
{
if (request is not AddFriendRequest req)
return false;
if (req.TargetId != nonFriend.OnlineID)
return false;
var apiRelation = new APIRelation
{
TargetID = nonFriend.OnlineID,
Mutual = true,
RelationType = RelationType.Friend,
TargetUser = nonFriend
};
Task.Run(() =>
{
requestLock.Wait(3000);
dummyAPI.Friends.Add(apiRelation);
req.TriggerSuccess(new AddFriendResponse
{
UserRelation = apiRelation
});
});
return true;
};
});
AddStep("clear friend list", () => dummyAPI.Friends.Clear());
AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo));
AddStep("Click followers button", () => this.ChildrenOfType<FollowersButton>().First().TriggerClick());
AddStep("Complete request", () => requestLock.Set());
AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID));
}
[Test]
public void TestAddFriendNonMutual()
{
AddStep("Setup request", () =>
{
requestLock.Reset();
dummyAPI.HandleRequest = request =>
{
if (request is not AddFriendRequest req)
return false;
if (req.TargetId != nonFriend.OnlineID)
return false;
var apiRelation = new APIRelation
{
TargetID = nonFriend.OnlineID,
Mutual = false,
RelationType = RelationType.Friend,
TargetUser = nonFriend
};
Task.Run(() =>
{
requestLock.Wait(3000);
dummyAPI.Friends.Add(apiRelation);
req.TriggerSuccess(new AddFriendResponse
{
UserRelation = apiRelation
});
});
return true;
};
});
AddStep("clear friend list", () => dummyAPI.Friends.Clear());
AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo));
AddStep("Click followers button", () => this.ChildrenOfType<FollowersButton>().First().TriggerClick());
AddStep("Complete request", () => requestLock.Set());
AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID));
}
} }
} }

View File

@ -414,11 +414,7 @@ namespace osu.Game.Tests.Visual.Settings
}); });
AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre)); AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre));
scrollToAndStartBinding("Left (centre)"); scrollToAndStartBinding("Left (centre)");
AddStep("clear binding", () => clearBinding();
{
var row = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text.ToString() == "Left (centre)"));
row.ChildrenOfType<KeyBindingRow.ClearButton>().Single().TriggerClick();
});
scrollToAndStartBinding("Left (rim)"); scrollToAndStartBinding("Left (rim)");
AddStep("bind M1", () => InputManager.Click(MouseButton.Left)); AddStep("bind M1", () => InputManager.Click(MouseButton.Left));
@ -431,6 +427,45 @@ namespace osu.Game.Tests.Visual.Settings
AddUntilStep("conflict popover not shown", () => panel.ChildrenOfType<KeyBindingConflictPopover>().SingleOrDefault(), () => Is.Null); AddUntilStep("conflict popover not shown", () => panel.ChildrenOfType<KeyBindingConflictPopover>().SingleOrDefault(), () => Is.Null);
} }
[Test]
public void TestResettingRowCannotConflictWithItself()
{
AddStep("reset taiko section to default", () =>
{
var section = panel.ChildrenOfType<VariantBindingsSubsection>().First(section => new TaikoRuleset().RulesetInfo.Equals(section.Ruleset));
section.ChildrenOfType<ResetButton>().Single().TriggerClick();
});
AddStep("move mouse to centre", () => InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre));
scrollToAndStartBinding("Left (centre)");
clearBinding();
scrollToAndStartBinding("Left (centre)", 1);
clearBinding();
scrollToAndStartBinding("Left (centre)");
AddStep("bind F", () => InputManager.Key(Key.F));
scrollToAndStartBinding("Left (centre)", 1);
AddStep("bind M1", () => InputManager.Click(MouseButton.Left));
AddStep("revert row to default", () =>
{
var row = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text.ToString() == "Left (centre)"));
InputManager.MoveMouseTo(row.ChildrenOfType<RevertToDefaultButton<bool>>().Single());
InputManager.Click(MouseButton.Left);
});
AddWaitStep("wait a bit", 3);
AddUntilStep("conflict popover not shown", () => panel.ChildrenOfType<KeyBindingConflictPopover>().SingleOrDefault(), () => Is.Null);
}
private void clearBinding()
{
AddStep("clear binding", () =>
{
var row = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text.ToString() == "Left (centre)"));
row.ChildrenOfType<KeyBindingRow.ClearButton>().Single().TriggerClick();
});
}
private void checkBinding(string name, string keyName) private void checkBinding(string name, string keyName)
{ {
AddAssert($"Check {name} is bound to {keyName}", () => AddAssert($"Check {name} is bound to {keyName}", () =>
@ -442,23 +477,23 @@ namespace osu.Game.Tests.Visual.Settings
}, () => Is.EqualTo(keyName)); }, () => Is.EqualTo(keyName));
} }
private void scrollToAndStartBinding(string name) private void scrollToAndStartBinding(string name, int bindingIndex = 0)
{ {
KeyBindingRow.KeyButton firstButton = null; KeyBindingRow.KeyButton targetButton = null;
AddStep($"Scroll to {name}", () => AddStep($"Scroll to {name}", () =>
{ {
var firstRow = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text.ToString() == name)); var firstRow = panel.ChildrenOfType<KeyBindingRow>().First(r => r.ChildrenOfType<OsuSpriteText>().Any(s => s.Text.ToString() == name));
firstButton = firstRow.ChildrenOfType<KeyBindingRow.KeyButton>().First(); targetButton = firstRow.ChildrenOfType<KeyBindingRow.KeyButton>().ElementAt(bindingIndex);
panel.ChildrenOfType<SettingsPanel.SettingsSectionsContainer>().First().ScrollTo(firstButton); panel.ChildrenOfType<SettingsPanel.SettingsSectionsContainer>().First().ScrollTo(targetButton);
}); });
AddWaitStep("wait for scroll", 5); AddWaitStep("wait for scroll", 5);
AddStep("click to bind", () => AddStep("click to bind", () =>
{ {
InputManager.MoveMouseTo(firstButton); InputManager.MoveMouseTo(targetButton);
InputManager.Click(MouseButton.Left); InputManager.Click(MouseButton.Left);
}); });
} }

View File

@ -10,9 +10,12 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mania;
@ -191,8 +194,39 @@ namespace osu.Game.Tests.Visual.SongSelect
{ {
AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); AddStep("present beatmap", () => Game.PresentBeatmap(getImport()));
AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded);
AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.MatchesOnlineID(getImport().Beatmaps[expectedDiff - 1])); AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.MatchesOnlineID(getImport().Beatmaps[expectedDiff - 1]));
} }
protected override TestOsuGame CreateTestGame() => new NoBeatmapUpdateGame(LocalStorage, API);
private partial class NoBeatmapUpdateGame : TestOsuGame
{
public NoBeatmapUpdateGame(Storage storage, IAPIProvider api, string[] args = null)
: base(storage, api, args)
{
}
protected override IBeatmapUpdater CreateBeatmapUpdater() => new TestBeatmapUpdater();
private class TestBeatmapUpdater : IBeatmapUpdater
{
public void Queue(Live<BeatmapSetInfo> beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
}
public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
}
public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
}
public void Dispose()
{
}
}
}
} }
} }

View File

@ -1,14 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Newtonsoft.Json;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Models; using osu.Game.Models;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Skinning.Components; using osu.Game.Skinning.Components;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Beatmaps;
@ -30,6 +44,8 @@ namespace osu.Game.Tests.Visual.UserInterface
[SetUp] [SetUp]
public void Setup() => Schedule(() => public void Setup() => Schedule(() =>
{ {
SelectedMods.SetDefault();
Ruleset.Value = new OsuRuleset().RulesetInfo;
Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo)
{ {
BeatmapInfo = BeatmapInfo =
@ -93,6 +109,126 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("check new title", getText, () => Is.EqualTo("Title: Another")); AddAssert("check new title", getText, () => Is.EqualTo("Title: Another"));
} }
[Test]
public void TestWithMods()
{
AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo)
{
BeatmapInfo =
{
BPM = 100,
Length = 30000,
Difficulty =
{
ApproachRate = 10,
CircleSize = 9
}
}
}));
test(BeatmapAttribute.BPM, new OsuModDoubleTime(), "BPM: 100.00", "BPM: 150.00");
test(BeatmapAttribute.Length, new OsuModDoubleTime(), "Length: 00:30", "Length: 00:20");
test(BeatmapAttribute.ApproachRate, new OsuModDoubleTime(), "Approach Rate: 10.00", "Approach Rate: 11.00");
test(BeatmapAttribute.CircleSize, new OsuModHardRock(), "Circle Size: 9.00", "Circle Size: 10.00");
void test(BeatmapAttribute attribute, Mod mod, string before, string after)
{
AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute);
AddAssert("check text is correct", getText, () => Is.EqualTo(before));
AddStep("add DT mod", () => SelectedMods.Value = new[] { mod });
AddAssert("check text is correct", getText, () => Is.EqualTo(after));
AddStep("clear mods", () => SelectedMods.SetDefault());
}
}
[Test]
public void TestStarRating()
{
AddStep("set test ruleset", () => Ruleset.Value = new TestRuleset().RulesetInfo);
AddStep("set star rating attribute", () => text.Attribute.Value = BeatmapAttribute.StarRating);
AddAssert("check star rating is 0", getText, () => Is.EqualTo("Star Rating: 0.00"));
// Adding mod
TestMod mod = null!;
AddStep("add mod with difficulty 1", () => SelectedMods.Value = new[] { mod = new TestMod { Difficulty = { Value = 1 } } });
AddUntilStep("check star rating is 1", getText, () => Is.EqualTo("Star Rating: 1.00"));
// Changing mod setting
AddStep("change mod difficulty to 2", () => mod.Difficulty.Value = 2);
AddUntilStep("check star rating is 2", getText, () => Is.EqualTo("Star Rating: 2.00"));
}
private string getText() => text.ChildrenOfType<SpriteText>().Single().Text.ToString(); private string getText() => text.ChildrenOfType<SpriteText>().Single().Text.ToString();
private class TestRuleset : Ruleset
{
public override IEnumerable<Mod> GetModsFor(ModType type) => new[]
{
new TestMod()
};
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap)
=> new OsuRuleset().CreateBeatmapConverter(beatmap);
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap)
=> new TestDifficultyCalculator(new TestRuleset().RulesetInfo, beatmap);
public override PerformanceCalculator CreatePerformanceCalculator()
=> new TestPerformanceCalculator(new TestRuleset());
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
=> null!;
public override string Description => string.Empty;
public override string ShortName => string.Empty;
}
private class TestDifficultyCalculator : DifficultyCalculator
{
public TestDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
=> new DifficultyAttributes(mods, mods.OfType<TestMod>().SingleOrDefault()?.Difficulty.Value ?? 0);
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
=> Array.Empty<DifficultyHitObject>();
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
=> Array.Empty<Skill>();
}
private class TestPerformanceCalculator : PerformanceCalculator
{
public TestPerformanceCalculator(Ruleset ruleset)
: base(ruleset)
{
}
protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes)
=> new PerformanceAttributes { Total = score.Mods.OfType<TestMod>().SingleOrDefault()?.Performance.Value ?? 0 };
}
private class TestMod : Mod
{
[SettingSource("difficulty")]
public BindableDouble Difficulty { get; } = new BindableDouble(0);
[SettingSource("performance")]
public BindableDouble Performance { get; } = new BindableDouble(0);
[JsonConstructor]
public TestMod()
{
}
public override string Name => string.Empty;
public override LocalisableString Description => string.Empty;
public override double ScoreMultiplier => 1.0;
public override string Acronym => "Test";
}
} }
} }

View File

@ -60,12 +60,18 @@ namespace osu.Game.Audio
/// </summary> /// </summary>
public int Volume { get; } public int Volume { get; }
public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100) /// <summary>
/// Whether this sample should automatically assign the bank of the normal sample whenever it is set in the editor.
/// </summary>
public bool EditorAutoBank { get; }
public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100, bool editorAutoBank = true)
{ {
Name = name; Name = name;
Bank = bank; Bank = bank;
Suffix = suffix; Suffix = suffix;
Volume = volume; Volume = volume;
EditorAutoBank = editorAutoBank;
} }
/// <summary> /// <summary>
@ -92,9 +98,10 @@ namespace osu.Game.Audio
/// <param name="newBank">An optional new sample bank.</param> /// <param name="newBank">An optional new sample bank.</param>
/// <param name="newSuffix">An optional new lookup suffix.</param> /// <param name="newSuffix">An optional new lookup suffix.</param>
/// <param name="newVolume">An optional new volume.</param> /// <param name="newVolume">An optional new volume.</param>
/// <param name="newEditorAutoBank">An optional new editor auto bank flag.</param>
/// <returns>The new <see cref="HitSampleInfo"/>.</returns> /// <returns>The new <see cref="HitSampleInfo"/>.</returns>
public virtual HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default) public virtual HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default)
=> new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank));
public virtual bool Equals(HitSampleInfo? other) public virtual bool Equals(HitSampleInfo? other)
=> other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix; => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix;

View File

@ -33,7 +33,7 @@ namespace osu.Game.Beatmaps
Debug.Assert(beatmapInfo.BeatmapSet != null); Debug.Assert(beatmapInfo.BeatmapSet != null);
var req = new GetBeatmapRequest(beatmapInfo); var req = new GetBeatmapRequest(md5Hash: beatmapInfo.MD5Hash, filename: beatmapInfo.Path);
try try
{ {

View File

@ -13,11 +13,11 @@ namespace osu.Game.Beatmaps
/// </summary> /// </summary>
public partial class BeatmapOnlineChangeIngest : Component public partial class BeatmapOnlineChangeIngest : Component
{ {
private readonly BeatmapUpdater beatmapUpdater; private readonly IBeatmapUpdater beatmapUpdater;
private readonly RealmAccess realm; private readonly RealmAccess realm;
private readonly MetadataClient metadataClient; private readonly MetadataClient metadataClient;
public BeatmapOnlineChangeIngest(BeatmapUpdater beatmapUpdater, RealmAccess realm, MetadataClient metadataClient) public BeatmapOnlineChangeIngest(IBeatmapUpdater beatmapUpdater, RealmAccess realm, MetadataClient metadataClient)
{ {
this.beatmapUpdater = beatmapUpdater; this.beatmapUpdater = beatmapUpdater;
this.realm = realm; this.realm = realm;

View File

@ -1,7 +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.
using System;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -15,10 +14,7 @@ using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Beatmaps namespace osu.Game.Beatmaps
{ {
/// <summary> public class BeatmapUpdater : IBeatmapUpdater
/// Handles all processing required to ensure a local beatmap is in a consistent state with any changes.
/// </summary>
public class BeatmapUpdater : IDisposable
{ {
private readonly IWorkingBeatmapCache workingBeatmapCache; private readonly IWorkingBeatmapCache workingBeatmapCache;
@ -38,11 +34,6 @@ namespace osu.Game.Beatmaps
metadataLookup = new BeatmapUpdaterMetadataLookup(api, storage); metadataLookup = new BeatmapUpdaterMetadataLookup(api, storage);
} }
/// <summary>
/// Queue a beatmap for background processing.
/// </summary>
/// <param name="beatmapSet">The managed beatmap set to update. A transaction will be opened to apply changes.</param>
/// <param name="lookupScope">The preferred scope to use for metadata lookup.</param>
public void Queue(Live<BeatmapSetInfo> beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) public void Queue(Live<BeatmapSetInfo> beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{ {
Logger.Log($"Queueing change for local beatmap {beatmapSet}"); Logger.Log($"Queueing change for local beatmap {beatmapSet}");
@ -50,12 +41,9 @@ namespace osu.Game.Beatmaps
updateScheduler); updateScheduler);
} }
/// <summary> public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
/// Run all processing on a beatmap immediately. {
/// </summary> beatmapSet.Realm!.Write(_ =>
/// <param name="beatmapSet">The managed beatmap set to update. A transaction will be opened to apply changes.</param>
/// <param name="lookupScope">The preferred scope to use for metadata lookup.</param>
public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm!.Write(_ =>
{ {
// Before we use below, we want to invalidate. // Before we use below, we want to invalidate.
workingBeatmapCache.Invalidate(beatmapSet); workingBeatmapCache.Invalidate(beatmapSet);
@ -84,8 +72,11 @@ namespace osu.Game.Beatmaps
// And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
workingBeatmapCache.Invalidate(beatmapSet); workingBeatmapCache.Invalidate(beatmapSet);
}); });
}
public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapInfo.Realm!.Write(_ => public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
beatmapInfo.Realm!.Write(_ =>
{ {
// Before we use below, we want to invalidate. // Before we use below, we want to invalidate.
workingBeatmapCache.Invalidate(beatmapInfo); workingBeatmapCache.Invalidate(beatmapInfo);
@ -99,6 +90,7 @@ namespace osu.Game.Beatmaps
// And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required.
workingBeatmapCache.Invalidate(beatmapInfo); workingBeatmapCache.Invalidate(beatmapInfo);
}); });
}
#region Implementation of IDisposable #region Implementation of IDisposable

View File

@ -5,7 +5,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using osu.Framework.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -44,10 +43,19 @@ namespace osu.Game.Beatmaps
foreach (var beatmapInfo in beatmapSet.Beatmaps) foreach (var beatmapInfo in beatmapSet.Beatmaps)
{ {
// note that these lookups DO NOT ACTUALLY FULLY GUARANTEE that the beatmap is what it claims it is,
// i.e. the correctness of this lookup should be treated as APPROXIMATE AT WORST.
// this is because the beatmap filename is used as a fallback in some scenarios where the MD5 of the beatmap may mismatch.
// this is considered to be an acceptable casualty so that things can continue to work as expected for users in some rare scenarios
// (stale beatmap files in beatmap packs, beatmap mirror desyncs).
// however, all this means that other places such as score submission ARE EXPECTED TO VERIFY THE MD5 OF THE BEATMAP AGAINST THE ONLINE ONE EXPLICITLY AGAIN.
//
// additionally note that the online ID stored to the map is EXPLICITLY NOT USED because some users in a silly attempt to "fix" things for themselves on stable
// would reuse online IDs of already submitted beatmaps, which means that information is pretty much expected to be bogus in a nonzero number of beatmapsets.
if (!tryLookup(beatmapInfo, preferOnlineFetch, out var res)) if (!tryLookup(beatmapInfo, preferOnlineFetch, out var res))
continue; continue;
if (res == null || shouldDiscardLookupResult(res, beatmapInfo)) if (res == null)
{ {
beatmapInfo.ResetOnlineInfo(); beatmapInfo.ResetOnlineInfo();
lookupResults.Add(null); // mark lookup failure lookupResults.Add(null); // mark lookup failure
@ -83,23 +91,6 @@ namespace osu.Game.Beatmaps
} }
} }
private bool shouldDiscardLookupResult(OnlineBeatmapMetadata result, BeatmapInfo beatmapInfo)
{
if (beatmapInfo.OnlineID > 0 && result.BeatmapID != beatmapInfo.OnlineID)
{
Logger.Log($"Discarding metadata lookup result due to mismatching online ID (expected: {beatmapInfo.OnlineID} actual: {result.BeatmapID})", LoggingTarget.Database);
return true;
}
if (beatmapInfo.OnlineID == -1 && result.MD5Hash != beatmapInfo.MD5Hash)
{
Logger.Log($"Discarding metadata lookup result due to mismatching hash (expected: {beatmapInfo.MD5Hash} actual: {result.MD5Hash})", LoggingTarget.Database);
return true;
}
return false;
}
/// <summary> /// <summary>
/// Attempts to retrieve the <see cref="OnlineBeatmapMetadata"/> for the given <paramref name="beatmapInfo"/>. /// Attempts to retrieve the <see cref="OnlineBeatmapMetadata"/> for the given <paramref name="beatmapInfo"/>.
/// </summary> /// </summary>

View File

@ -183,7 +183,17 @@ namespace osu.Game.Beatmaps.Formats
if (scrollSpeedEncodedAsSliderVelocity) if (scrollSpeedEncodedAsSliderVelocity)
{ {
foreach (var point in legacyControlPoints.EffectPoints) foreach (var point in legacyControlPoints.EffectPoints)
legacyControlPoints.Add(point.Time, new DifficultyControlPoint { SliderVelocity = point.ScrollSpeed }); {
legacyControlPoints.Add(point.Time, new DifficultyControlPoint
{
SliderVelocityBindable =
{
MinValue = point.ScrollSpeedBindable.MinValue,
MaxValue = point.ScrollSpeedBindable.MaxValue,
Value = point.ScrollSpeedBindable.Value,
}
});
}
} }
LegacyControlPointProperties lastControlPointProperties = new LegacyControlPointProperties(); LegacyControlPointProperties lastControlPointProperties = new LegacyControlPointProperties();
@ -539,7 +549,7 @@ namespace osu.Game.Beatmaps.Formats
private string getSampleBank(IList<HitSampleInfo> samples, bool banksOnly = false) private string getSampleBank(IList<HitSampleInfo> samples, bool banksOnly = false)
{ {
LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank); LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank);
LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL)?.Bank); LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL && !s.EditorAutoBank)?.Bank);
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();

View File

@ -0,0 +1,35 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Database;
namespace osu.Game.Beatmaps
{
/// <summary>
/// Handles all processing required to ensure a local beatmap is in a consistent state with any changes.
/// </summary>
public interface IBeatmapUpdater : IDisposable
{
/// <summary>
/// Queue a beatmap for background processing.
/// </summary>
/// <param name="beatmapSet">The managed beatmap set to update. A transaction will be opened to apply changes.</param>
/// <param name="lookupScope">The preferred scope to use for metadata lookup.</param>
void Queue(Live<BeatmapSetInfo> beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst);
/// <summary>
/// Run all processing on a beatmap immediately.
/// </summary>
/// <param name="beatmapSet">The managed beatmap set to update. A transaction will be opened to apply changes.</param>
/// <param name="lookupScope">The preferred scope to use for metadata lookup.</param>
void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst);
/// <summary>
/// Runs a subset of processing focused on updating any cached beatmap object counts.
/// </summary>
/// <param name="beatmapInfo">The managed beatmap to update. A transaction will be opened to apply changes.</param>
/// <param name="lookupScope">The preferred scope to use for metadata lookup.</param>
void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst);
}
}

View File

@ -90,8 +90,7 @@ namespace osu.Game.Beatmaps
} }
if (string.IsNullOrEmpty(beatmapInfo.MD5Hash) if (string.IsNullOrEmpty(beatmapInfo.MD5Hash)
&& string.IsNullOrEmpty(beatmapInfo.Path) && string.IsNullOrEmpty(beatmapInfo.Path))
&& beatmapInfo.OnlineID <= 0)
{ {
onlineMetadata = null; onlineMetadata = null;
return false; return false;
@ -240,10 +239,9 @@ namespace osu.Game.Beatmaps
using var cmd = db.CreateCommand(); using var cmd = db.CreateCommand();
cmd.CommandText = cmd.CommandText =
@"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR filename = @Path";
cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash));
cmd.Parameters.Add(new SqliteParameter(@"@OnlineID", beatmapInfo.OnlineID));
cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path));
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@ -281,11 +279,10 @@ namespace osu.Game.Beatmaps
SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date` SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date`
FROM `osu_beatmaps` AS `b` FROM `osu_beatmaps` AS `b`
JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id` JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id`
WHERE `b`.`checksum` = @MD5Hash OR `b`.`beatmap_id` = @OnlineID OR `b`.`filename` = @Path WHERE `b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path
"""; """;
cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash));
cmd.Parameters.Add(new SqliteParameter(@"@OnlineID", beatmapInfo.OnlineID));
cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path));
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();

View File

@ -17,6 +17,7 @@ using osu.Game.Localisation;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Mods.Input; using osu.Game.Overlays.Mods.Input;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.Select; using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter; using osu.Game.Screens.Select.Filter;
@ -193,6 +194,8 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.EditorAutoSeekOnPlacement, true); SetDefault(OsuSetting.EditorAutoSeekOnPlacement, true);
SetDefault(OsuSetting.EditorLimitedDistanceSnap, false); SetDefault(OsuSetting.EditorLimitedDistanceSnap, false);
SetDefault(OsuSetting.EditorShowSpeedChanges, false); SetDefault(OsuSetting.EditorShowSpeedChanges, false);
SetDefault(OsuSetting.EditorScaleOrigin, EditorOrigin.GridCentre);
SetDefault(OsuSetting.EditorRotationOrigin, EditorOrigin.GridCentre);
SetDefault(OsuSetting.HideCountryFlags, false); SetDefault(OsuSetting.HideCountryFlags, false);
@ -204,8 +207,11 @@ namespace osu.Game.Configuration
SetDefault<UserStatus?>(OsuSetting.UserOnlineStatus, null); SetDefault<UserStatus?>(OsuSetting.UserOnlineStatus, null);
SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true); SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true);
SetDefault(OsuSetting.EditorTimelineShowBreaks, true);
SetDefault(OsuSetting.EditorTimelineShowTicks, true); SetDefault(OsuSetting.EditorTimelineShowTicks, true);
SetDefault(OsuSetting.EditorContractSidebars, false);
SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false); SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false);
} }
@ -431,6 +437,10 @@ namespace osu.Game.Configuration
HideCountryFlags, HideCountryFlags,
EditorTimelineShowTimingChanges, EditorTimelineShowTimingChanges,
EditorTimelineShowTicks, EditorTimelineShowTicks,
AlwaysShowHoldForMenuButton AlwaysShowHoldForMenuButton,
EditorContractSidebars,
EditorScaleOrigin,
EditorRotationOrigin,
EditorTimelineShowBreaks,
} }
} }

View File

@ -46,7 +46,7 @@ namespace osu.Game.Database
private RealmAccess realmAccess { get; set; } = null!; private RealmAccess realmAccess { get; set; } = null!;
[Resolved] [Resolved]
private BeatmapUpdater beatmapUpdater { get; set; } = null!; private IBeatmapUpdater beatmapUpdater { get; set; } = null!;
[Resolved] [Resolved]
private IBindable<WorkingBeatmap> gameBeatmap { get; set; } = null!; private IBindable<WorkingBeatmap> gameBeatmap { get; set; } = null!;

View File

@ -528,7 +528,7 @@ namespace osu.Game.Database
/// <param name="model">The new model proposed for import.</param> /// <param name="model">The new model proposed for import.</param>
/// <param name="realm">The current realm context.</param> /// <param name="realm">The current realm context.</param>
/// <returns>An existing model which matches the criteria to skip importing, else null.</returns> /// <returns>An existing model which matches the criteria to skip importing, else null.</returns>
protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All<TModel>().FirstOrDefault(b => b.Hash == model.Hash); protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All<TModel>().OrderBy(b => b.DeletePending).FirstOrDefault(b => b.Hash == model.Hash);
/// <summary> /// <summary>
/// Whether import can be skipped after finding an existing import early in the process. /// Whether import can be skipped after finding an existing import early in the process.

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Localisation;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -77,7 +78,7 @@ namespace osu.Game.Graphics.UserInterface
if (Link != null) if (Link != null)
{ {
items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => game?.OpenUrlExternally(Link))); items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => game?.OpenUrlExternally(Link)));
items.Add(new OsuMenuItem("Copy link", MenuItemType.Standard, copyUrl)); items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, copyUrl));
} }
return items.ToArray(); return items.ToArray();

View File

@ -98,7 +98,7 @@ namespace osu.Game.Graphics.UserInterface
{ {
const float vertical_offset = 13; const float vertical_offset = 13;
InternalChildren = new Drawable[] InternalChildren = new[]
{ {
label = new OsuSpriteText label = new OsuSpriteText
{ {
@ -115,7 +115,9 @@ namespace osu.Game.Graphics.UserInterface
KeyboardStep = 0.1f, KeyboardStep = 0.1f,
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Y = vertical_offset, Y = vertical_offset,
} },
upperBound.Nub.CreateProxy(),
lowerBound.Nub.CreateProxy(),
}; };
} }
@ -160,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface
protected partial class BoundSlider : RoundedSliderBar<double> protected partial class BoundSlider : RoundedSliderBar<double>
{ {
public new Nub Nub => base.Nub;
public string? DefaultString; public string? DefaultString;
public LocalisableString? DefaultTooltip; public LocalisableString? DefaultTooltip;
public string? TooltipSuffix; public string? TooltipSuffix;

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -25,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface
private readonly HoverClickSounds hoverClickSounds; private readonly HoverClickSounds hoverClickSounds;
private readonly Container mainContent;
private Color4 accentColour; private Color4 accentColour;
public Color4 AccentColour public Color4 AccentColour
@ -62,7 +65,7 @@ namespace osu.Game.Graphics.UserInterface
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Padding = new MarginPadding { Horizontal = 2 }, Padding = new MarginPadding { Horizontal = 2 },
Child = new CircularContainer Child = mainContent = new CircularContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
@ -135,6 +138,26 @@ namespace osu.Game.Graphics.UserInterface
}, true); }, true);
} }
protected override void OnFocus(FocusEvent e)
{
base.OnFocus(e);
mainContent.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Colour = AccentColour.Darken(1),
Hollow = true,
Radius = 2,
};
}
protected override void OnFocusLost(FocusLostEvent e)
{
base.OnFocusLost(e);
mainContent.EdgeEffect = default;
}
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)
{ {
updateGlow(); updateGlow();

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -26,6 +27,8 @@ namespace osu.Game.Graphics.UserInterface
private readonly HoverClickSounds hoverClickSounds; private readonly HoverClickSounds hoverClickSounds;
private readonly Container mainContent;
private Color4 accentColour; private Color4 accentColour;
public Color4 AccentColour public Color4 AccentColour
@ -60,12 +63,13 @@ namespace osu.Game.Graphics.UserInterface
RangePadding = EXPANDED_SIZE / 2; RangePadding = EXPANDED_SIZE / 2;
Children = new Drawable[] Children = new Drawable[]
{ {
new Container mainContent = new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 5,
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Padding = new MarginPadding { Horizontal = 2 },
Child = new Container Child = new Container
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -138,6 +142,26 @@ namespace osu.Game.Graphics.UserInterface
}, true); }, true);
} }
protected override void OnFocus(FocusEvent e)
{
base.OnFocus(e);
mainContent.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Colour = AccentColour.Darken(1),
Hollow = true,
Radius = 2,
};
}
protected override void OnFocusLost(FocusLostEvent e)
{
base.OnFocusLost(e);
mainContent.EdgeEffect = default;
}
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)
{ {
updateGlow(); updateGlow();
@ -167,8 +191,8 @@ namespace osu.Game.Graphics.UserInterface
protected override void UpdateAfterChildren() protected override void UpdateAfterChildren()
{ {
base.UpdateAfterChildren(); base.UpdateAfterChildren();
LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1);
RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1);
} }
protected override void UpdateValue(float value) protected override void UpdateValue(float value)

View File

@ -71,7 +71,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
private Box background = null!; private Box background = null!;
private Box flashLayer = null!; private Box flashLayer = null!;
private FormTextBox.InnerTextBox textBox = null!; private FormTextBox.InnerTextBox textBox = null!;
private Slider slider = null!; private InnerSlider slider = null!;
private FormFieldCaption caption = null!; private FormFieldCaption caption = null!;
private IFocusManager focusManager = null!; private IFocusManager focusManager = null!;
@ -135,7 +135,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
}, },
TabbableContentContainer = tabbableContentContainer, TabbableContentContainer = tabbableContentContainer,
}, },
slider = new Slider slider = new InnerSlider
{ {
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,
@ -163,6 +163,7 @@ namespace osu.Game.Graphics.UserInterfaceV2
textBox.Current.BindValueChanged(textChanged); textBox.Current.BindValueChanged(textChanged);
slider.IsDragging.BindValueChanged(_ => updateState()); slider.IsDragging.BindValueChanged(_ => updateState());
slider.Focused.BindValueChanged(_ => updateState());
current.ValueChanged += e => currentNumberInstantaneous.Value = e.NewValue; current.ValueChanged += e => currentNumberInstantaneous.Value = e.NewValue;
current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v; current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v;
@ -259,16 +260,18 @@ namespace osu.Game.Graphics.UserInterfaceV2
private void updateState() private void updateState()
{ {
bool childHasFocus = slider.Focused.Value || textBox.Focused.Value;
textBox.Alpha = 1; textBox.Alpha = 1;
background.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background4 : colourProvider.Background5; background.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background4 : colourProvider.Background5;
caption.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; caption.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content2;
textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content1;
BorderThickness = IsHovered || textBox.Focused.Value || slider.IsDragging.Value ? 2 : 0; BorderThickness = childHasFocus || IsHovered || slider.IsDragging.Value ? 2 : 0;
BorderColour = textBox.Focused.Value ? colourProvider.Highlight1 : colourProvider.Light4; BorderColour = childHasFocus ? colourProvider.Highlight1 : colourProvider.Light4;
if (textBox.Focused.Value) if (childHasFocus)
background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3);
else if (IsHovered || slider.IsDragging.Value) else if (IsHovered || slider.IsDragging.Value)
background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4);
@ -283,8 +286,10 @@ namespace osu.Game.Graphics.UserInterfaceV2
textBox.Text = slider.GetDisplayableValue(currentNumberInstantaneous.Value).ToString(); textBox.Text = slider.GetDisplayableValue(currentNumberInstantaneous.Value).ToString();
} }
private partial class Slider : OsuSliderBar<T> private partial class InnerSlider : OsuSliderBar<T>
{ {
public BindableBool Focused { get; } = new BindableBool();
public BindableBool IsDragging { get; set; } = new BindableBool(); public BindableBool IsDragging { get; set; } = new BindableBool();
public Action? OnCommit { get; set; } public Action? OnCommit { get; set; }
@ -344,7 +349,6 @@ namespace osu.Game.Graphics.UserInterfaceV2
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
updateState(); updateState();
} }
@ -382,11 +386,25 @@ namespace osu.Game.Graphics.UserInterfaceV2
base.OnHoverLost(e); base.OnHoverLost(e);
} }
protected override void OnFocus(FocusEvent e)
{
updateState();
Focused.Value = true;
base.OnFocus(e);
}
protected override void OnFocusLost(FocusLostEvent e)
{
updateState();
Focused.Value = false;
base.OnFocusLost(e);
}
private void updateState() private void updateState()
{ {
rightBox.Colour = colourProvider.Background6; rightBox.Colour = colourProvider.Background6;
leftBox.Colour = IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Dark2; leftBox.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Dark2;
nub.Colour = IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4; nub.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4;
} }
protected override void UpdateValue(float value) protected override void UpdateValue(float value)

View File

@ -174,6 +174,11 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString General => new TranslatableString(getKey(@"general"), @"General"); public static LocalisableString General => new TranslatableString(getKey(@"general"), @"General");
/// <summary>
/// "Copy link"
/// </summary>
public static LocalisableString CopyLink => new TranslatableString(getKey(@"copy_link"), @"Copy link");
private static string getKey(string key) => $@"{prefix}:{key}"; private static string getKey(string key) => $@"{prefix}:{key}";
} }
} }

View File

@ -114,6 +114,11 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString LimitedDistanceSnap => new TranslatableString(getKey(@"limited_distance_snap_grid"), @"Limit distance snap placement to current time"); public static LocalisableString LimitedDistanceSnap => new TranslatableString(getKey(@"limited_distance_snap_grid"), @"Limit distance snap placement to current time");
/// <summary>
/// "Contract sidebars when not hovered"
/// </summary>
public static LocalisableString ContractSidebars => new TranslatableString(getKey(@"contract_sidebars"), @"Contract sidebars when not hovered");
/// <summary> /// <summary>
/// "Must be in edit mode to handle editor links" /// "Must be in edit mode to handle editor links"
/// </summary> /// </summary>
@ -134,6 +139,11 @@ namespace osu.Game.Localisation
/// </summary> /// </summary>
public static LocalisableString TimelineShowTimingChanges => new TranslatableString(getKey(@"timeline_show_timing_changes"), @"Show timing changes"); public static LocalisableString TimelineShowTimingChanges => new TranslatableString(getKey(@"timeline_show_timing_changes"), @"Show timing changes");
/// <summary>
/// "Show breaks"
/// </summary>
public static LocalisableString TimelineShowBreaks => new TranslatableString(getKey(@"timeline_show_breaks"), @"Show breaks");
/// <summary> /// <summary>
/// "Show ticks" /// "Show ticks"
/// </summary> /// </summary>

View File

@ -26,10 +26,10 @@ namespace osu.Game.Localisation
/// <summary> /// <summary>
/// "No performance points will be awarded. /// "No performance points will be awarded.
/// Leaderboards may be reset by the beatmap creator." /// Leaderboards may be reset."
/// </summary> /// </summary>
public static LocalisableString LovedBeatmapDisclaimerContent => new TranslatableString(getKey(@"loved_beatmap_disclaimer_content"), @"No performance points will be awarded. public static LocalisableString LovedBeatmapDisclaimerContent => new TranslatableString(getKey(@"loved_beatmap_disclaimer_content"), @"No performance points will be awarded.
Leaderboards may be reset by the beatmap creator."); Leaderboards may be reset.");
/// <summary> /// <summary>
/// "This beatmap is qualified" /// "This beatmap is qualified"

View File

@ -57,7 +57,7 @@ namespace osu.Game.Online.API
private string password; private string password;
public IBindable<APIUser> LocalUser => localUser; public IBindable<APIUser> LocalUser => localUser;
public IBindableList<APIUser> Friends => friends; public IBindableList<APIRelation> Friends => friends;
public IBindable<UserActivity> Activity => activity; public IBindable<UserActivity> Activity => activity;
public IBindable<UserStatistics> Statistics => statistics; public IBindable<UserStatistics> Statistics => statistics;
@ -67,7 +67,7 @@ namespace osu.Game.Online.API
private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser()); private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser());
private BindableList<APIUser> friends { get; } = new BindableList<APIUser>(); private BindableList<APIRelation> friends { get; } = new BindableList<APIRelation>();
private Bindable<UserActivity> activity { get; } = new Bindable<UserActivity>(); private Bindable<UserActivity> activity { get; } = new Bindable<UserActivity>();
@ -360,19 +360,7 @@ namespace osu.Game.Online.API
} }
} }
var friendsReq = new GetFriendsRequest(); UpdateLocalFriends();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res =>
{
friends.Clear();
friends.AddRange(res);
};
if (!handleRequest(friendsReq))
{
state.Value = APIState.Failing;
return;
}
// The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding.
// Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests
@ -624,6 +612,22 @@ namespace osu.Game.Online.API
localUser.Value.Statistics = newStatistics; localUser.Value.Statistics = newStatistics;
} }
public void UpdateLocalFriends()
{
if (!IsLoggedIn)
return;
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res =>
{
friends.Clear();
friends.AddRange(res);
};
Queue(friendsReq);
}
private static APIUser createGuestUser() => new GuestUser(); private static APIUser createGuestUser() => new GuestUser();
private void setLocalUser(APIUser user) => Scheduler.Add(() => private void setLocalUser(APIUser user) => Scheduler.Add(() =>

View File

@ -26,7 +26,7 @@ namespace osu.Game.Online.API
Id = DUMMY_USER_ID, Id = DUMMY_USER_ID,
}); });
public BindableList<APIUser> Friends { get; } = new BindableList<APIUser>(); public BindableList<APIRelation> Friends { get; } = new BindableList<APIRelation>();
public Bindable<UserActivity> Activity { get; } = new Bindable<UserActivity>(); public Bindable<UserActivity> Activity { get; } = new Bindable<UserActivity>();
@ -201,6 +201,10 @@ namespace osu.Game.Online.API
LocalUser.Value.Statistics = newStatistics; LocalUser.Value.Statistics = newStatistics;
} }
public void UpdateLocalFriends()
{
}
public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null;
public IChatClient GetChatClient() => new TestChatClientConnector(this); public IChatClient GetChatClient() => new TestChatClientConnector(this);
@ -214,7 +218,7 @@ namespace osu.Game.Online.API
public void SetState(APIState newState) => state.Value = newState; public void SetState(APIState newState) => state.Value = newState;
IBindable<APIUser> IAPIProvider.LocalUser => LocalUser; IBindable<APIUser> IAPIProvider.LocalUser => LocalUser;
IBindableList<APIUser> IAPIProvider.Friends => Friends; IBindableList<APIRelation> IAPIProvider.Friends => Friends;
IBindable<UserActivity> IAPIProvider.Activity => Activity; IBindable<UserActivity> IAPIProvider.Activity => Activity;
IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics; IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics;

View File

@ -22,7 +22,7 @@ namespace osu.Game.Online.API
/// <summary> /// <summary>
/// The user's friends. /// The user's friends.
/// </summary> /// </summary>
IBindableList<APIUser> Friends { get; } IBindableList<APIRelation> Friends { get; }
/// <summary> /// <summary>
/// The current user's activity. /// The current user's activity.
@ -134,6 +134,11 @@ namespace osu.Game.Online.API
/// </summary> /// </summary>
void UpdateStatistics(UserStatistics newStatistics); void UpdateStatistics(UserStatistics newStatistics);
/// <summary>
/// Update the friends status of the current user.
/// </summary>
void UpdateLocalFriends();
/// <summary> /// <summary>
/// Schedule a callback to run on the update thread. /// Schedule a callback to run on the update thread.
/// </summary> /// </summary>

View File

@ -0,0 +1,30 @@
// 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.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class AddFriendRequest : APIRequest<AddFriendResponse>
{
public readonly int TargetId;
public AddFriendRequest(int targetId)
{
TargetId = targetId;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query);
return req;
}
protected override string Target => @"friends";
}
}

View File

@ -0,0 +1,14 @@
// 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 Newtonsoft.Json;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
public class AddFriendResponse
{
[JsonProperty("user_relation")]
public APIRelation UserRelation = null!;
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class DeleteFriendRequest : APIRequest
{
public readonly int TargetId;
public DeleteFriendRequest(int targetId)
{
TargetId = targetId;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Delete;
return req;
}
protected override string Target => $@"friends/{TargetId}";
}
}

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.Globalization;
using osu.Framework.IO.Network; using osu.Framework.IO.Network;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API.Requests.Responses;
@ -9,23 +10,30 @@ namespace osu.Game.Online.API.Requests
{ {
public class GetBeatmapRequest : APIRequest<APIBeatmap> public class GetBeatmapRequest : APIRequest<APIBeatmap>
{ {
public readonly IBeatmapInfo BeatmapInfo; public readonly int OnlineID;
public readonly string Filename; public readonly string? MD5Hash;
public readonly string? Filename;
public GetBeatmapRequest(IBeatmapInfo beatmapInfo) public GetBeatmapRequest(IBeatmapInfo beatmapInfo)
: this(onlineId: beatmapInfo.OnlineID, md5Hash: beatmapInfo.MD5Hash, filename: (beatmapInfo as BeatmapInfo)?.Path)
{ {
BeatmapInfo = beatmapInfo; }
Filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty;
public GetBeatmapRequest(int onlineId = -1, string? md5Hash = null, string? filename = null)
{
OnlineID = onlineId;
MD5Hash = md5Hash;
Filename = filename;
} }
protected override WebRequest CreateWebRequest() protected override WebRequest CreateWebRequest()
{ {
var request = base.CreateWebRequest(); var request = base.CreateWebRequest();
if (BeatmapInfo.OnlineID > 0) if (OnlineID > 0)
request.AddParameter(@"id", BeatmapInfo.OnlineID.ToString()); request.AddParameter(@"id", OnlineID.ToString(CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(BeatmapInfo.MD5Hash)) if (!string.IsNullOrEmpty(MD5Hash))
request.AddParameter(@"checksum", BeatmapInfo.MD5Hash); request.AddParameter(@"checksum", MD5Hash);
if (!string.IsNullOrEmpty(Filename)) if (!string.IsNullOrEmpty(Filename))
request.AddParameter(@"filename", Filename); request.AddParameter(@"filename", Filename);

View File

@ -6,7 +6,7 @@ using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests namespace osu.Game.Online.API.Requests
{ {
public class GetFriendsRequest : APIRequest<List<APIUser>> public class GetFriendsRequest : APIRequest<List<APIRelation>>
{ {
protected override string Target => @"friends"; protected override string Target => @"friends";
} }

View File

@ -0,0 +1,30 @@
// 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 Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace osu.Game.Online.API.Requests.Responses
{
public class APIRelation
{
[JsonProperty("target_id")]
public int TargetID { get; set; }
[JsonProperty("relation_type")]
public RelationType RelationType { get; set; }
[JsonProperty("mutual")]
public bool Mutual { get; set; }
[JsonProperty("target")]
public APIUser? TargetUser { get; set; }
}
[JsonConverter(typeof(StringEnumConverter))]
public enum RelationType
{
Friend,
Block,
}
}

View File

@ -11,7 +11,7 @@ using osu.Game.Configuration;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Dialog;
using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Online.Chat namespace osu.Game.Online.Chat
{ {
@ -60,12 +60,12 @@ namespace osu.Game.Online.Chat
}, },
new PopupDialogCancelButton new PopupDialogCancelButton
{ {
Text = @"Copy link", Text = CommonStrings.CopyLink,
Action = copyExternalLinkAction Action = copyExternalLinkAction
}, },
new PopupDialogCancelButton new PopupDialogCancelButton
{ {
Text = CommonStrings.ButtonsCancel, Text = WebCommonStrings.ButtonsCancel,
}, },
}; };
} }

View File

@ -17,6 +17,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
@ -33,6 +34,8 @@ using osu.Game.Online.API;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Utils; using osu.Game.Utils;
using CommonStrings = osu.Game.Localisation.CommonStrings;
using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Online.Leaderboards namespace osu.Game.Online.Leaderboards
{ {
@ -71,6 +74,12 @@ namespace osu.Game.Online.Leaderboards
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private SongSelect songSelect { get; set; } private SongSelect songSelect { get; set; }
[Resolved(canBeNull: true)]
private Clipboard clipboard { get; set; }
[Resolved]
private IAPIProvider api { get; set; }
public ITooltip<ScoreInfo> GetCustomTooltip() => new LeaderboardScoreTooltip(); public ITooltip<ScoreInfo> GetCustomTooltip() => new LeaderboardScoreTooltip();
public virtual ScoreInfo TooltipContent => Score; public virtual ScoreInfo TooltipContent => Score;
@ -423,10 +432,13 @@ namespace osu.Game.Online.Leaderboards
if (Score.Mods.Length > 0 && songSelect != null) if (Score.Mods.Length > 0 && songSelect != null)
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods));
if (Score.OnlineID > 0)
items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{Score.OnlineID}")));
if (Score.Files.Count > 0) if (Score.Files.Count > 0)
{ {
items.Add(new OsuMenuItem(Localisation.CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score))); items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(Score)));
items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); items.Add(new OsuMenuItem(WebCommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score))));
} }
return items.ToArray(); return items.ToArray();

View File

@ -202,7 +202,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(joinedRoom.Playlist.Count > 0); Debug.Assert(joinedRoom.Playlist.Count > 0);
APIRoom.Playlist.Clear(); APIRoom.Playlist.Clear();
APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(item => new PlaylistItem(item)));
APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId);
// The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client.
@ -734,7 +734,7 @@ namespace osu.Game.Online.Multiplayer
Debug.Assert(APIRoom != null); Debug.Assert(APIRoom != null);
Room.Playlist.Add(item); Room.Playlist.Add(item);
APIRoom.Playlist.Add(createPlaylistItem(item)); APIRoom.Playlist.Add(new PlaylistItem(item));
ItemAdded?.Invoke(item); ItemAdded?.Invoke(item);
RoomUpdated?.Invoke(); RoomUpdated?.Invoke();
@ -780,7 +780,7 @@ namespace osu.Game.Online.Multiplayer
int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID));
APIRoom.Playlist.RemoveAt(existingIndex); APIRoom.Playlist.RemoveAt(existingIndex);
APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); APIRoom.Playlist.Insert(existingIndex, new PlaylistItem(item));
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -853,18 +853,6 @@ namespace osu.Game.Online.Multiplayer
RoomUpdated?.Invoke(); RoomUpdated?.Invoke();
} }
private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating })
{
ID = item.ID,
OwnerID = item.OwnerID,
RulesetID = item.RulesetID,
Expired = item.Expired,
PlaylistOrder = item.PlaylistOrder,
PlayedAt = item.PlayedAt,
RequiredMods = item.RequiredMods.ToArray(),
AllowedMods = item.AllowedMods.ToArray()
};
/// <summary> /// <summary>
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>. /// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.
/// </summary> /// </summary>

View File

@ -0,0 +1,39 @@
// 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 osu.Game.Online.Rooms;
namespace osu.Game.Online.Multiplayer
{
public static class MultiplayerRoomExtensions
{
/// <summary>
/// Returns all historical/expired items from the <paramref name="room"/>, in the order in which they were played.
/// </summary>
public static IEnumerable<MultiplayerPlaylistItem> GetHistoricalItems(this MultiplayerRoom room)
=> room.Playlist.Where(item => item.Expired).OrderBy(item => item.PlayedAt);
/// <summary>
/// Returns all non-expired items from the <paramref name="room"/>, in the order in which they are to be played.
/// </summary>
public static IEnumerable<MultiplayerPlaylistItem> GetUpcomingItems(this MultiplayerRoom room)
=> room.Playlist.Where(item => !item.Expired).OrderBy(item => item.PlaylistOrder);
/// <summary>
/// Returns the first non-expired <see cref="MultiplayerPlaylistItem"/> in playlist order from the supplied <paramref name="room"/>,
/// or the last-played <see cref="MultiplayerPlaylistItem"/> if all items are expired,
/// or <see langword="null"/> if <paramref name="room"/> was empty.
/// </summary>
public static MultiplayerPlaylistItem? GetCurrentItem(this MultiplayerRoom room)
{
if (room.Playlist.Count == 0)
return null;
return room.Playlist.All(item => item.Expired)
? GetHistoricalItems(room).Last()
: GetUpcomingItems(room).First();
}
}
}

View File

@ -198,7 +198,7 @@ namespace osu.Game
public readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> AvailableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>(new Dictionary<ModType, IReadOnlyList<Mod>>()); public readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> AvailableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>(new Dictionary<ModType, IReadOnlyList<Mod>>());
private BeatmapDifficultyCache difficultyCache; private BeatmapDifficultyCache difficultyCache;
private BeatmapUpdater beatmapUpdater; private IBeatmapUpdater beatmapUpdater;
private UserLookupCache userCache; private UserLookupCache userCache;
private BeatmapLookupCache beatmapCache; private BeatmapLookupCache beatmapCache;
@ -324,7 +324,7 @@ namespace osu.Game
base.Content.Add(difficultyCache); base.Content.Add(difficultyCache);
// TODO: OsuGame or OsuGameBase? // TODO: OsuGame or OsuGameBase?
dependencies.CacheAs(beatmapUpdater = new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage)); dependencies.CacheAs(beatmapUpdater = CreateBeatmapUpdater());
dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints)); dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints));
dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints)); dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints));
dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints)); dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints));
@ -563,6 +563,8 @@ namespace osu.Game
} }
} }
protected virtual IBeatmapUpdater CreateBeatmapUpdater() => new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage);
protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager();
protected virtual BatteryInfo CreateBatteryInfo() => null; protected virtual BatteryInfo CreateBatteryInfo() => null;

View File

@ -6,14 +6,17 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
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.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Users; using osu.Game.Users;
using osuTK; using osuTK;
@ -35,7 +38,8 @@ namespace osu.Game.Overlays.Dashboard.Friends
private CancellationTokenSource cancellationToken; private CancellationTokenSource cancellationToken;
private Drawable currentContent; [CanBeNull]
private SearchContainer currentContent;
private FriendOnlineStreamControl onlineStreamControl; private FriendOnlineStreamControl onlineStreamControl;
private Box background; private Box background;
@ -43,8 +47,9 @@ namespace osu.Game.Overlays.Dashboard.Friends
private UserListToolbar userListToolbar; private UserListToolbar userListToolbar;
private Container itemsPlaceholder; private Container itemsPlaceholder;
private LoadingLayer loading; private LoadingLayer loading;
private BasicSearchTextBox searchTextBox;
private readonly IBindableList<APIUser> apiFriends = new BindableList<APIUser>(); private readonly IBindableList<APIRelation> apiFriends = new BindableList<APIRelation>();
public FriendDisplay() public FriendDisplay()
{ {
@ -104,7 +109,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
Margin = new MarginPadding { Bottom = 20 }, Margin = new MarginPadding { Bottom = 20 },
Children = new Drawable[] Children = new Drawable[]
{ {
new Container new GridContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
@ -113,11 +118,38 @@ namespace osu.Game.Overlays.Dashboard.Friends
Horizontal = 40, Horizontal = 40,
Vertical = 20 Vertical = 20
}, },
Child = userListToolbar = new UserListToolbar ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.Absolute, 50),
new Dimension(GridSizeMode.AutoSize),
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new[]
{
searchTextBox = new BasicSearchTextBox
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Height = 40,
ReleaseFocusOnCommit = false,
HoldFocus = true,
PlaceholderText = HomeStrings.SearchPlaceholder,
},
Empty(),
userListToolbar = new UserListToolbar
{ {
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,
} },
},
},
}, },
new Container new Container
{ {
@ -145,7 +177,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
controlBackground.Colour = colourProvider.Background5; controlBackground.Colour = colourProvider.Background5;
apiFriends.BindTo(api.Friends); apiFriends.BindTo(api.Friends);
apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.ToList()), true); apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.Select(f => f.TargetUser).ToList()), true);
} }
protected override void LoadComplete() protected override void LoadComplete()
@ -155,6 +187,11 @@ namespace osu.Game.Overlays.Dashboard.Friends
onlineStreamControl.Current.BindValueChanged(_ => recreatePanels()); onlineStreamControl.Current.BindValueChanged(_ => recreatePanels());
userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels()); userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels());
userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels());
searchTextBox.Current.BindValueChanged(_ =>
{
if (currentContent.IsNotNull())
currentContent.SearchTerm = searchTextBox.Current.Value;
});
} }
private void recreatePanels() private void recreatePanels()
@ -188,7 +225,7 @@ namespace osu.Game.Overlays.Dashboard.Friends
} }
} }
private void addContentToPlaceholder(Drawable content) private void addContentToPlaceholder(SearchContainer content)
{ {
loading.Hide(); loading.Hide();
@ -204,16 +241,17 @@ namespace osu.Game.Overlays.Dashboard.Friends
currentContent.FadeIn(200, Easing.OutQuint); currentContent.FadeIn(200, Easing.OutQuint);
} }
private FillFlowContainer createTable(List<APIUser> users) private SearchContainer createTable(List<APIUser> users)
{ {
var style = userListToolbar.DisplayStyle.Value; var style = userListToolbar.DisplayStyle.Value;
return new FillFlowContainer return new SearchContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2),
Children = users.Select(u => createUserPanel(u, style)).ToList() Children = users.Select(u => createUserPanel(u, style)).ToList(),
SearchTerm = searchTextBox.Current.Value,
}; };
} }

View File

@ -1,11 +1,21 @@
// 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 osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Notifications;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
using SharpCompress;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
{ {
@ -13,15 +23,201 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<UserProfileData?> User = new Bindable<UserProfileData?>(); public readonly Bindable<UserProfileData?> User = new Bindable<UserProfileData?>();
public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled; // Because it is impossible to update the number of friends after the operation,
// the number of friends obtained is stored and modified locally.
private int followerCount;
public override LocalisableString TooltipText
{
get
{
switch (status.Value)
{
case FriendStatus.Self:
return FriendsStrings.ButtonsDisabled;
case FriendStatus.None:
return FriendsStrings.ButtonsAdd;
case FriendStatus.NotMutual:
case FriendStatus.Mutual:
return FriendsStrings.ButtonsRemove;
}
return FriendsStrings.TitleCompact;
}
}
protected override IconUsage Icon => FontAwesome.Solid.User; protected override IconUsage Icon => FontAwesome.Solid.User;
private readonly IBindableList<APIRelation> apiFriends = new BindableList<APIRelation>();
private readonly IBindable<APIUser> localUser = new Bindable<APIUser>();
private readonly Bindable<FriendStatus> status = new Bindable<FriendStatus>();
[Resolved]
private OsuColour colour { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(IAPIProvider api, INotificationOverlay? notifications)
{ {
// todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly. localUser.BindTo(api.LocalUser);
User.BindValueChanged(user => SetValue(user.NewValue?.User.FollowerCount ?? 0), true);
status.BindValueChanged(_ =>
{
updateIcon();
updateColor();
});
User.BindValueChanged(u =>
{
followerCount = u.NewValue?.User.FollowerCount ?? 0;
updateStatus();
}, true);
apiFriends.BindTo(api.Friends);
apiFriends.BindCollectionChanged((_, _) => Schedule(updateStatus));
Action += () =>
{
if (User.Value == null)
return;
if (status.Value == FriendStatus.Self)
return;
ShowLoadingLayer();
APIRequest req = status.Value == FriendStatus.None ? new AddFriendRequest(User.Value.User.OnlineID) : new DeleteFriendRequest(User.Value.User.OnlineID);
req.Success += () =>
{
if (req is AddFriendRequest addedRequest)
{
SetValue(++followerCount);
status.Value = addedRequest.Response?.UserRelation.Mutual == true ? FriendStatus.Mutual : FriendStatus.NotMutual;
}
else
{
SetValue(--followerCount);
status.Value = FriendStatus.None;
}
api.UpdateLocalFriends();
HideLoadingLayer();
};
req.Failure += e =>
{
notifications?.Post(new SimpleNotification
{
Text = e.Message,
Icon = FontAwesome.Solid.Times,
});
HideLoadingLayer();
};
api.Queue(req);
};
}
protected override bool OnHover(HoverEvent e)
{
if (status.Value > FriendStatus.None)
{
SetIcon(FontAwesome.Solid.UserTimes);
}
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
updateIcon();
}
private void updateStatus()
{
SetValue(followerCount);
if (localUser.Value.OnlineID == User.Value?.User.OnlineID)
{
status.Value = FriendStatus.Self;
return;
}
var friend = apiFriends.FirstOrDefault(u => User.Value?.User.OnlineID == u.TargetID);
if (friend != null)
{
status.Value = friend.Mutual ? FriendStatus.Mutual : FriendStatus.NotMutual;
}
else
{
status.Value = FriendStatus.None;
}
}
private void updateIcon()
{
switch (status.Value)
{
case FriendStatus.Self:
SetIcon(FontAwesome.Solid.User);
break;
case FriendStatus.None:
SetIcon(FontAwesome.Solid.UserPlus);
break;
case FriendStatus.NotMutual:
SetIcon(FontAwesome.Solid.User);
break;
case FriendStatus.Mutual:
SetIcon(FontAwesome.Solid.UserFriends);
break;
}
}
private void updateColor()
{
// https://github.com/ppy/osu-web/blob/0a5367a4a68a6cdf450eb483251b3cf03b3ac7d2/resources/css/bem/user-action-button.less
switch (status.Value)
{
case FriendStatus.Self:
case FriendStatus.None:
IdleColour = colourProvider.Background6;
HoverColour = colourProvider.Background5;
break;
case FriendStatus.NotMutual:
IdleColour = colour.Green.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
break;
case FriendStatus.Mutual:
IdleColour = colour.Pink.Opacity(0.7f);
HoverColour = IdleColour.Lighten(0.1f);
break;
}
EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint));
}
private enum FriendStatus
{
Self,
None,
NotMutual,
Mutual,
} }
} }
} }

View File

@ -7,6 +7,7 @@ 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.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
{ {
@ -14,6 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
private readonly Box background; private readonly Box background;
private readonly Container content; private readonly Container content;
private readonly LoadingLayer loading;
protected override Container<Drawable> Content => content; protected override Container<Drawable> Content => content;
@ -40,11 +42,22 @@ namespace osu.Game.Overlays.Profile.Header.Components
AutoSizeAxes = Axes.X, AutoSizeAxes = Axes.X,
RelativeSizeAxes = Axes.Y, RelativeSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10 }, Padding = new MarginPadding { Horizontal = 10 },
} },
loading = new LoadingLayer(true, false)
} }
}); });
} }
protected void ShowLoadingLayer()
{
loading.Show();
}
protected void HideLoadingLayer()
{
loading.Hide();
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider) private void load(OverlayColourProvider colourProvider)
{ {

View File

@ -14,6 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
public abstract partial class ProfileHeaderStatisticsButton : ProfileHeaderButton public abstract partial class ProfileHeaderStatisticsButton : ProfileHeaderButton
{ {
private readonly OsuSpriteText drawableText; private readonly OsuSpriteText drawableText;
private readonly Container iconContainer;
protected ProfileHeaderStatisticsButton() protected ProfileHeaderStatisticsButton()
{ {
@ -26,13 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components
Direction = FillDirection.Horizontal, Direction = FillDirection.Horizontal,
Children = new Drawable[] Children = new Drawable[]
{ {
new SpriteIcon iconContainer = new Container
{ {
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Icon = Icon, AutoSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Size = new Vector2(50, 14)
}, },
drawableText = new OsuSpriteText drawableText = new OsuSpriteText
{ {
@ -43,10 +42,24 @@ namespace osu.Game.Overlays.Profile.Header.Components
} }
} }
}; };
SetIcon(Icon);
} }
protected abstract IconUsage Icon { get; } protected abstract IconUsage Icon { get; }
protected void SetIcon(IconUsage icon)
{
iconContainer.Child = new SpriteIcon
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Icon = icon,
FillMode = FillMode.Fit,
Size = new Vector2(50, 14)
};
}
protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0"); protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0");
} }
} }

View File

@ -222,7 +222,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input
var button = buttons[i++]; var button = buttons[i++];
button.UpdateKeyCombination(d); button.UpdateKeyCombination(d);
tryPersistKeyBinding(button.KeyBinding.Value, advanceToNextBinding: false); tryPersistKeyBinding(button.KeyBinding.Value, advanceToNextBinding: false, restoringDefaults: true);
} }
isDefault.Value = true; isDefault.Value = true;
@ -489,12 +489,25 @@ namespace osu.Game.Overlays.Settings.Sections.Input
base.OnFocusLost(e); base.OnFocusLost(e);
} }
private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding) private bool isConflictingBinding(RealmKeyBinding first, RealmKeyBinding second, bool restoringDefaults)
{
if (first.ID == second.ID)
return false;
// ignore conflicts with same action bindings during revert. the assumption is that the other binding will be reverted subsequently in the same higher-level operation.
// this happens if the bindings for an action are rebound to the same keys, but the ordering of the bindings itself is different.
if (restoringDefaults && first.ActionInt == second.ActionInt)
return false;
return first.KeyCombination.Equals(second.KeyCombination);
}
private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding, bool restoringDefaults = false)
{ {
List<RealmKeyBinding> bindings = GetAllSectionBindings(); List<RealmKeyBinding> bindings = GetAllSectionBindings();
RealmKeyBinding? existingBinding = keyBinding.KeyCombination.Equals(new KeyCombination(InputKey.None)) RealmKeyBinding? existingBinding = keyBinding.KeyCombination.Equals(new KeyCombination(InputKey.None))
? null ? null
: bindings.FirstOrDefault(other => other.ID != keyBinding.ID && other.KeyCombination.Equals(keyBinding.KeyCombination)); : bindings.FirstOrDefault(other => isConflictingBinding(keyBinding, other, restoringDefaults));
if (existingBinding == null) if (existingBinding == null)
{ {

View File

@ -122,7 +122,10 @@ namespace osu.Game.Overlays.Toolbar
rulesetSelectionChannel[r.NewValue] = channel; rulesetSelectionChannel[r.NewValue] = channel;
channel.Play(); channel.Play();
musicController?.DuckMomentarily(500, new DuckParameters { DuckDuration = 0 });
// Longer unduck delay for Mania sample
int unduckDelay = r.NewValue.OnlineID == 3 ? 750 : 500;
musicController?.DuckMomentarily(unduckDelay, new DuckParameters { DuckDuration = 0 });
} }
public override bool HandleNonPositionalInput => !Current.Disabled && base.HandleNonPositionalInput; public override bool HandleNonPositionalInput => !Current.Disabled && base.HandleNonPositionalInput;

View File

@ -29,6 +29,7 @@ namespace osu.Game.Rulesets.Difficulty
protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23; protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23;
protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25; protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25;
protected const int ATTRIB_ID_OK_HIT_WINDOW = 27; protected const int ATTRIB_ID_OK_HIT_WINDOW = 27;
protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29;
/// <summary> /// <summary>
/// The mods which were applied to the beatmap. /// The mods which were applied to the beatmap.

View File

@ -1,9 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Screens.Edit;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
@ -12,6 +16,15 @@ namespace osu.Game.Rulesets.Edit
{ {
protected override double HoverExpansionDelay => 250; protected override double HoverExpansionDelay => 250;
protected override bool ExpandOnHover => expandOnHover;
private readonly Bindable<bool> contractSidebars = new Bindable<bool>();
private bool expandOnHover;
[Resolved]
private Editor? editor { get; set; }
public ExpandingToolboxContainer(float contractedWidth, float expandedWidth) public ExpandingToolboxContainer(float contractedWidth, float expandedWidth)
: base(contractedWidth, expandedWidth) : base(contractedWidth, expandedWidth)
{ {
@ -19,6 +32,27 @@ namespace osu.Game.Rulesets.Edit
FillFlow.Spacing = new Vector2(5); FillFlow.Spacing = new Vector2(5);
FillFlow.Padding = new MarginPadding { Vertical = 5 }; FillFlow.Padding = new MarginPadding { Vertical = 5 };
Expanded.Value = true;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
config.BindWith(OsuSetting.EditorContractSidebars, contractSidebars);
}
protected override void Update()
{
base.Update();
bool requireContracting = contractSidebars.Value || editor?.DrawSize.X / editor?.DrawSize.Y < 1.7f;
if (expandOnHover != requireContracting)
{
expandOnHover = requireContracting;
Expanded.Value = !expandOnHover;
}
} }
protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos);

View File

@ -344,8 +344,8 @@ namespace osu.Game.Rulesets.Edit
PlayfieldContentContainer.Anchor = Anchor.CentreLeft; PlayfieldContentContainer.Anchor = Anchor.CentreLeft;
PlayfieldContentContainer.Origin = Anchor.CentreLeft; PlayfieldContentContainer.Origin = Anchor.CentreLeft;
PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth) - (TOOLBOX_CONTRACTED_SIZE_LEFT + TOOLBOX_CONTRACTED_SIZE_RIGHT); PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth);
PlayfieldContentContainer.X = TOOLBOX_CONTRACTED_SIZE_LEFT; PlayfieldContentContainer.X = LeftToolbox.DrawWidth;
} }
composerFocusMode.Value = PlayfieldContentContainer.Contains(InputManager.CurrentState.Mouse.Position) composerFocusMode.Value = PlayfieldContentContainer.Contains(InputManager.CurrentState.Mouse.Position)

View File

@ -233,7 +233,7 @@ namespace osu.Game.Rulesets.Objects
// Fall back to using the normal sample bank otherwise. // Fall back to using the normal sample bank otherwise.
if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingNormal) if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingNormal)
return existingNormal.With(newName: sampleName); return existingNormal.With(newName: sampleName, newEditorAutoBank: true);
return new HitSampleInfo(sampleName); return new HitSampleInfo(sampleName);
} }

View File

@ -204,8 +204,14 @@ namespace osu.Game.Rulesets.Objects.Legacy
if (stringBank == @"none") if (stringBank == @"none")
stringBank = null; stringBank = null;
string stringAddBank = addBank.ToString().ToLowerInvariant(); string stringAddBank = addBank.ToString().ToLowerInvariant();
if (stringAddBank == @"none") if (stringAddBank == @"none")
{
bankInfo.EditorAutoBank = true;
stringAddBank = null; stringAddBank = null;
}
else
bankInfo.EditorAutoBank = false;
bankInfo.BankForNormal = stringBank; bankInfo.BankForNormal = stringBank;
bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank;
@ -477,7 +483,7 @@ namespace osu.Game.Rulesets.Objects.Legacy
if (string.IsNullOrEmpty(bankInfo.Filename)) if (string.IsNullOrEmpty(bankInfo.Filename))
{ {
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, bankInfo.CustomSampleBank, soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, true, bankInfo.CustomSampleBank,
// if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample.
// None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds
type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal))); type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal)));
@ -489,13 +495,13 @@ namespace osu.Game.Rulesets.Objects.Legacy
} }
if (type.HasFlag(LegacyHitSoundType.Finish)) if (type.HasFlag(LegacyHitSoundType.Finish))
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank));
if (type.HasFlag(LegacyHitSoundType.Whistle)) if (type.HasFlag(LegacyHitSoundType.Whistle))
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank));
if (type.HasFlag(LegacyHitSoundType.Clap)) if (type.HasFlag(LegacyHitSoundType.Clap))
soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank));
return soundTypes; return soundTypes;
} }
@ -534,6 +540,11 @@ namespace osu.Game.Rulesets.Objects.Legacy
/// </summary> /// </summary>
public int CustomSampleBank; public int CustomSampleBank;
/// <summary>
/// Whether the bank for additions should be inherited from the normal sample in edit.
/// </summary>
public bool EditorAutoBank = true;
public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone();
} }
@ -558,21 +569,21 @@ namespace osu.Game.Rulesets.Objects.Legacy
/// </summary> /// </summary>
public bool BankSpecified; public bool BankSpecified;
public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int customSampleBank = 0, bool isLayered = false) public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, bool editorAutoBank = false, int customSampleBank = 0, bool isLayered = false)
: base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume, editorAutoBank)
{ {
CustomSampleBank = customSampleBank; CustomSampleBank = customSampleBank;
BankSpecified = !string.IsNullOrEmpty(bank); BankSpecified = !string.IsNullOrEmpty(bank);
IsLayered = isLayered; IsLayered = isLayered;
} }
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default) public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default)
=> With(newName, newBank, newVolume); => With(newName, newBank, newVolume, newEditorAutoBank);
public virtual LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, public virtual LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default,
Optional<int> newCustomSampleBank = default, Optional<int> newCustomSampleBank = default,
Optional<bool> newIsLayered = default) Optional<bool> newIsLayered = default)
=> new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered));
public bool Equals(LegacyHitSampleInfo? other) public bool Equals(LegacyHitSampleInfo? other)
// The additions to equality checks here are *required* to ensure that pooling works correctly. // The additions to equality checks here are *required* to ensure that pooling works correctly.
@ -604,7 +615,7 @@ namespace osu.Game.Rulesets.Objects.Legacy
Path.ChangeExtension(Filename, null) Path.ChangeExtension(Filename, null)
}; };
public sealed override LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, public sealed override LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default,
Optional<int> newCustomSampleBank = default, Optional<int> newCustomSampleBank = default,
Optional<bool> newIsLayered = default) Optional<bool> newIsLayered = default)
=> new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume));

View File

@ -101,7 +101,7 @@ namespace osu.Game.Rulesets
/// <param name="acronym">The acronym to query for .</param> /// <param name="acronym">The acronym to query for .</param>
public Mod? CreateModFromAcronym(string acronym) public Mod? CreateModFromAcronym(string acronym)
{ {
return AllMods.FirstOrDefault(m => m.Acronym == acronym)?.CreateInstance(); return AllMods.FirstOrDefault(m => string.Equals(m.Acronym, acronym, StringComparison.OrdinalIgnoreCase))?.CreateInstance();
} }
/// <summary> /// <summary>

View File

@ -4,8 +4,10 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -14,7 +16,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.Edit.Components.TernaryButtons namespace osu.Game.Screens.Edit.Components.TernaryButtons
{ {
public partial class DrawableTernaryButton : OsuButton public partial class DrawableTernaryButton : OsuButton, IHasTooltip
{ {
private Color4 defaultBackgroundColour; private Color4 defaultBackgroundColour;
private Color4 defaultIconColour; private Color4 defaultIconColour;
@ -58,12 +60,16 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
base.LoadComplete(); base.LoadComplete();
Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); Button.Bindable.BindValueChanged(_ => updateSelectionState(), true);
Button.Enabled.BindTo(Enabled);
Action = onAction; Action = onAction;
} }
private void onAction() private void onAction()
{ {
if (!Button.Enabled.Value)
return;
Button.Toggle(); Button.Toggle();
} }
@ -98,5 +104,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
X = 40f X = 40f
}; };
public LocalisableString TooltipText => Button.Tooltip;
} }
} }

View File

@ -12,6 +12,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
{ {
public readonly Bindable<TernaryState> Bindable; public readonly Bindable<TernaryState> Bindable;
public readonly Bindable<bool> Enabled = new Bindable<bool>(true);
public readonly string Description; public readonly string Description;
/// <summary> /// <summary>
@ -19,6 +21,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons
/// </summary> /// </summary>
public readonly Func<Drawable>? CreateIcon; public readonly Func<Drawable>? CreateIcon;
public string Tooltip { get; set; } = string.Empty;
public TernaryButton(Bindable<TernaryState> bindable, string description, Func<Drawable>? createIcon = null) public TernaryButton(Bindable<TernaryState> bindable, string description, Func<Drawable>? createIcon = null)
{ {
Bindable = bindable; Bindable = bindable;

View File

@ -68,6 +68,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
SampleBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionBankStates).ToArray(); SampleBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionBankStates).ToArray();
SampleAdditionBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionAdditionBankStates).ToArray(); SampleAdditionBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionAdditionBankStates).ToArray();
SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true);
SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true);
AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset)
{ {
Child = placementBlueprintContainer Child = placementBlueprintContainer
@ -288,6 +291,26 @@ namespace osu.Game.Screens.Edit.Compose.Components
return null; return null;
} }
private void updateAutoBankTernaryButtonTooltip()
{
bool enabled = SelectionHandler.AutoSelectionBankEnabled.Value;
var autoBankButton = SampleBankTernaryStates.Single(t => t.Bindable == SelectionHandler.SelectionBankStates[EditorSelectionHandler.HIT_BANK_AUTO]);
autoBankButton.Enabled.Value = enabled;
autoBankButton.Tooltip = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty;
}
private void updateAdditionBankTernaryButtonTooltips()
{
bool enabled = SelectionHandler.SelectionAdditionBanksEnabled.Value;
foreach (var ternaryButton in SampleAdditionBankTernaryStates)
{
ternaryButton.Enabled.Value = enabled;
ternaryButton.Tooltip = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty;
}
}
#region Placement #region Placement
/// <summary> /// <summary>

View File

@ -0,0 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
namespace osu.Game.Screens.Edit.Compose.Components
{
public enum EditorOrigin
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}
}

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
using Humanizer; using Humanizer;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -10,7 +11,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints;
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;
@ -37,7 +37,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
// bring in updates from selection changes // bring in updates from selection changes
EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates);
SelectedItems.CollectionChanged += (_, _) => Scheduler.AddOnce(UpdateTernaryStates); SelectedItems.CollectionChanged += onSelectedItemsChanged;
} }
protected override void DeleteItems(IEnumerable<HitObject> items) => EditorBeatmap.RemoveRange(items); protected override void DeleteItems(IEnumerable<HitObject> items) => EditorBeatmap.RemoveRange(items);
@ -64,6 +64,16 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// </summary> /// </summary>
public readonly Dictionary<string, Bindable<TernaryState>> SelectionAdditionBankStates = new Dictionary<string, Bindable<TernaryState>>(); public readonly Dictionary<string, Bindable<TernaryState>> SelectionAdditionBankStates = new Dictionary<string, Bindable<TernaryState>>();
/// <summary>
/// Whether there is no selection and the auto <see cref="SelectionBankStates"/> can be used.
/// </summary>
public readonly Bindable<bool> AutoSelectionBankEnabled = new Bindable<bool>();
/// <summary>
/// Whether the selection contains any addition samples and the <see cref="SelectionAdditionBankStates"/> can be used.
/// </summary>
public readonly Bindable<bool> SelectionAdditionBanksEnabled = new Bindable<bool>();
/// <summary> /// <summary>
/// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions)
/// </summary> /// </summary>
@ -153,10 +163,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
} }
else else
{ {
// Auto should never apply when there is a selection made.
if (bankName == HIT_BANK_AUTO)
break;
// Completely empty selections should be allowed in the case that none of the selected objects have any addition samples. // Completely empty selections should be allowed in the case that none of the selected objects have any addition samples.
// This is also required to stop a bindable feedback loop when a HitObject has zero addition samples (and LINQ `All` below becomes true). // This is also required to stop a bindable feedback loop when a HitObject has zero addition samples (and LINQ `All` below becomes true).
if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL)))
@ -164,9 +170,17 @@ namespace osu.Game.Screens.Edit.Compose.Components
// Never remove a sample bank. // Never remove a sample bank.
// These are basically radio buttons, not toggles. // These are basically radio buttons, not toggles.
if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName))) if (bankName == HIT_BANK_AUTO)
{
if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.EditorAutoBank)))
bindable.Value = TernaryState.True; bindable.Value = TernaryState.True;
} }
else
{
if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName && !s.EditorAutoBank)))
bindable.Value = TernaryState.True;
}
}
break; break;
@ -183,14 +197,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
} }
else else
{ {
// Auto should just not apply if there's a selection already made.
// Maybe we could make it a disabled button in the future, but right now the editor buttons don't support disabled state.
if (bankName == HIT_BANK_AUTO)
{
bindable.Value = TernaryState.False;
break;
}
// If none of the selected objects have any addition samples, we should not apply the addition bank. // If none of the selected objects have any addition samples, we should not apply the addition bank.
if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL)))
{ {
@ -208,9 +214,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
SelectionAdditionBankStates[bankName] = bindable; SelectionAdditionBankStates[bankName] = bindable;
} }
// start with normal selected. resetTernaryStates();
SelectionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True;
SelectionAdditionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True;
foreach (string sampleName in HitSampleInfo.AllAdditions) foreach (string sampleName in HitSampleInfo.AllAdditions)
{ {
@ -252,12 +256,21 @@ namespace osu.Game.Screens.Edit.Compose.Components
}; };
} }
private void resetTernaryStates()
{
AutoSelectionBankEnabled.Value = true;
SelectionAdditionBanksEnabled.Value = true;
SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True;
SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True;
}
/// <summary> /// <summary>
/// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated).
/// </summary> /// </summary>
protected virtual void UpdateTernaryStates() protected virtual void UpdateTernaryStates()
{ {
SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType<IHasComboInformation>(), h => h.NewCombo); SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType<IHasComboInformation>(), h => h.NewCombo);
AutoSelectionBankEnabled.Value = SelectedItems.Count == 0;
var samplesInSelection = SelectedItems.SelectMany(enumerateAllSamples).ToArray(); var samplesInSelection = SelectedItems.SelectMany(enumerateAllSamples).ToArray();
@ -271,12 +284,23 @@ namespace osu.Game.Screens.Edit.Compose.Components
bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName);
} }
SelectionAdditionBanksEnabled.Value = samplesInSelection.SelectMany(s => s).Any(o => o.Name != HitSampleInfo.HIT_NORMAL);
foreach ((string bankName, var bindable) in SelectionAdditionBankStates) foreach ((string bankName, var bindable) in SelectionAdditionBankStates)
{ {
bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank));
} }
} }
private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
// Reset the ternary states when the selection is cleared.
if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0)
Scheduler.AddOnce(resetTernaryStates);
else
Scheduler.AddOnce(UpdateTernaryStates);
}
private IEnumerable<IList<HitSampleInfo>> enumerateAllSamples(HitObject hitObject) private IEnumerable<IList<HitSampleInfo>> enumerateAllSamples(HitObject hitObject)
{ {
yield return hitObject.Samples; yield return hitObject.Samples;
@ -337,33 +361,29 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// <param name="bankName">The name of the sample bank.</param> /// <param name="bankName">The name of the sample bank.</param>
public void SetSampleAdditionBank(string bankName) public void SetSampleAdditionBank(string bankName)
{ {
bool hasRelevantBank(HitObject hitObject) bool hasRelevantBank(HitObject hitObject) =>
{ bankName == HIT_BANK_AUTO
bool result = hitObject.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); ? enumerateAllSamples(hitObject).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.EditorAutoBank)
: enumerateAllSamples(hitObject).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName && !s.EditorAutoBank);
if (hitObject is IHasRepeats hasRepeats)
{
foreach (var node in hasRepeats.NodeSamples)
result &= node.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName);
}
return result;
}
if (SelectedItems.All(hasRelevantBank)) if (SelectedItems.All(hasRelevantBank))
return; return;
EditorBeatmap.PerformOnSelection(h => EditorBeatmap.PerformOnSelection(h =>
{ {
if (enumerateAllSamples(h).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName)) if (hasRelevantBank(h))
return; return;
h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); string normalBank = h.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT;
h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList();
if (h is IHasRepeats hasRepeats) if (h is IHasRepeats hasRepeats)
{ {
for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i)
hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); {
normalBank = hasRepeats.NodeSamples[i].FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT;
hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList();
}
} }
EditorBeatmap.Update(h); EditorBeatmap.Update(h);
@ -407,9 +427,9 @@ namespace osu.Game.Screens.Edit.Compose.Components
var hitSample = h.CreateHitSampleInfo(sampleName); var hitSample = h.CreateHitSampleInfo(sampleName);
string? existingAdditionBank = node.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL)?.Bank; HitSampleInfo? existingAddition = node.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL);
if (existingAdditionBank != null) if (existingAddition != null)
hitSample = hitSample.With(newBank: existingAdditionBank); hitSample = hitSample.With(newBank: existingAddition.Bank, newEditorAutoBank: existingAddition.EditorAutoBank);
node.Add(hitSample); node.Add(hitSample);
} }

View File

@ -107,7 +107,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
public static string? GetAdditionBankValue(IEnumerable<HitSampleInfo> samples) public static string? GetAdditionBankValue(IEnumerable<HitSampleInfo> samples)
{ {
return samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL)?.Bank; var firstAddition = samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL);
if (firstAddition == null)
return null;
return firstAddition.EditorAutoBank ? EditorSelectionHandler.HIT_BANK_AUTO : firstAddition.Bank;
} }
public static int GetVolumeValue(ICollection<HitSampleInfo> samples) public static int GetVolumeValue(ICollection<HitSampleInfo> samples)
@ -320,7 +324,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
for (int i = 0; i < relevantSamples.Count; i++) for (int i = 0; i < relevantSamples.Count; i++)
{ {
if (relevantSamples[i].Name != HitSampleInfo.HIT_NORMAL) continue; if (relevantSamples[i].Name != HitSampleInfo.HIT_NORMAL && !relevantSamples[i].EditorAutoBank) continue;
relevantSamples[i] = relevantSamples[i].With(newBank: newBank); relevantSamples[i] = relevantSamples[i].With(newBank: newBank);
} }
@ -331,11 +335,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
updateAllRelevantSamples((_, relevantSamples) => updateAllRelevantSamples((_, relevantSamples) =>
{ {
string normalBank = relevantSamples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT;
for (int i = 0; i < relevantSamples.Count; i++) for (int i = 0; i < relevantSamples.Count; i++)
{ {
if (relevantSamples[i].Name == HitSampleInfo.HIT_NORMAL) continue; if (relevantSamples[i].Name == HitSampleInfo.HIT_NORMAL)
continue;
relevantSamples[i] = relevantSamples[i].With(newBank: newBank); // Addition samples with bank set to auto should inherit the bank of the normal sample
if (newBank == EditorSelectionHandler.HIT_BANK_AUTO)
{
relevantSamples[i] = relevantSamples[i].With(newBank: normalBank, newEditorAutoBank: true);
}
else
relevantSamples[i] = relevantSamples[i].With(newBank: newBank, newEditorAutoBank: false);
} }
}); });
} }
@ -383,7 +396,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
selectionSampleStates[sampleName] = bindable; selectionSampleStates[sampleName] = bindable;
} }
banks.AddRange(HitSampleInfo.AllBanks); banks.AddRange(HitSampleInfo.AllBanks.Prepend(EditorSelectionHandler.HIT_BANK_AUTO));
} }
private void updateTernaryStates() private void updateTernaryStates()
@ -448,7 +461,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
if (string.IsNullOrEmpty(newBank)) if (string.IsNullOrEmpty(newBank))
return true; return true;
if (e.ShiftPressed) if (e.ShiftPressed && newBank != EditorSelectionHandler.HIT_BANK_AUTO)
{ {
setBank(newBank); setBank(newBank);
updatePrimaryBankState(); updatePrimaryBankState();
@ -462,7 +475,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
} }
else else
{ {
var item = togglesCollection.ElementAtOrDefault(rightIndex); var item = togglesCollection.ElementAtOrDefault(rightIndex - 1);
if (item is not DrawableTernaryButton button) return base.OnKeyDown(e); if (item is not DrawableTernaryButton button) return base.OnKeyDown(e);
@ -476,18 +489,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
switch (key) switch (key)
{ {
case Key.W: case Key.Q:
index = 0; index = 0;
break; break;
case Key.E: case Key.W:
index = 1; index = 1;
break; break;
case Key.R: case Key.E:
index = 2; index = 2;
break; break;
case Key.R:
index = 3;
break;
default: default:
index = -1; index = -1;
break; break;

View File

@ -6,6 +6,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching; using osu.Framework.Caching;
using osu.Game.Beatmaps.Timing; using osu.Game.Beatmaps.Timing;
using osu.Game.Configuration;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline namespace osu.Game.Screens.Edit.Compose.Components.Timeline
@ -27,6 +28,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private readonly BindableList<BreakPeriod> breaks = new BindableList<BreakPeriod>(); private readonly BindableList<BreakPeriod> breaks = new BindableList<BreakPeriod>();
private readonly BindableBool showBreaks = new BindableBool(true);
[BackgroundDependencyLoader]
private void load(OsuConfigManager configManager)
{
configManager.BindWith(OsuSetting.EditorTimelineShowBreaks, showBreaks);
showBreaks.BindValueChanged(_ => breakCache.Invalidate());
}
protected override void LoadBeatmap(EditorBeatmap beatmap) protected override void LoadBeatmap(EditorBeatmap beatmap)
{ {
base.LoadBeatmap(beatmap); base.LoadBeatmap(beatmap);
@ -67,6 +77,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{ {
Clear(); Clear();
if (!showBreaks.Value)
return;
for (int i = 0; i < breaks.Count; i++) for (int i = 0; i < breaks.Count; i++)
{ {
var breakPeriod = breaks[i]; var breakPeriod = breaks[i];

View File

@ -214,7 +214,9 @@ namespace osu.Game.Screens.Edit
private Bindable<bool> editorAutoSeekOnPlacement; private Bindable<bool> editorAutoSeekOnPlacement;
private Bindable<bool> editorLimitedDistanceSnap; private Bindable<bool> editorLimitedDistanceSnap;
private Bindable<bool> editorTimelineShowTimingChanges; private Bindable<bool> editorTimelineShowTimingChanges;
private Bindable<bool> editorTimelineShowBreaks;
private Bindable<bool> editorTimelineShowTicks; private Bindable<bool> editorTimelineShowTicks;
private Bindable<bool> editorContractSidebars;
/// <summary> /// <summary>
/// This controls the opacity of components like the timelines, sidebars, etc. /// This controls the opacity of components like the timelines, sidebars, etc.
@ -322,7 +324,9 @@ namespace osu.Game.Screens.Edit
editorAutoSeekOnPlacement = config.GetBindable<bool>(OsuSetting.EditorAutoSeekOnPlacement); editorAutoSeekOnPlacement = config.GetBindable<bool>(OsuSetting.EditorAutoSeekOnPlacement);
editorLimitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap); editorLimitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
editorTimelineShowTimingChanges = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTimingChanges); editorTimelineShowTimingChanges = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTimingChanges);
editorTimelineShowBreaks = config.GetBindable<bool>(OsuSetting.EditorTimelineShowBreaks);
editorTimelineShowTicks = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTicks); editorTimelineShowTicks = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTicks);
editorContractSidebars = config.GetBindable<bool>(OsuSetting.EditorContractSidebars);
AddInternal(new OsuContextMenuContainer AddInternal(new OsuContextMenuContainer
{ {
@ -388,6 +392,10 @@ namespace osu.Game.Screens.Edit
{ {
State = { BindTarget = editorTimelineShowTicks } State = { BindTarget = editorTimelineShowTicks }
}, },
new ToggleMenuItem(EditorStrings.TimelineShowBreaks)
{
State = { BindTarget = editorTimelineShowBreaks }
},
] ]
}, },
new BackgroundDimMenuItem(editorBackgroundDim), new BackgroundDimMenuItem(editorBackgroundDim),
@ -402,7 +410,11 @@ namespace osu.Game.Screens.Edit
new ToggleMenuItem(EditorStrings.LimitedDistanceSnap) new ToggleMenuItem(EditorStrings.LimitedDistanceSnap)
{ {
State = { BindTarget = editorLimitedDistanceSnap }, State = { BindTarget = editorLimitedDistanceSnap },
} },
new ToggleMenuItem(EditorStrings.ContractSidebars)
{
State = { BindTarget = editorContractSidebars }
},
} }
}, },
new MenuItem(EditorStrings.Timing) new MenuItem(EditorStrings.Timing)

View File

@ -1,23 +1,25 @@
// 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 osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Online.Multiplayer;
using osuTK; using osuTK;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{ {
public partial class RankRangePill : MultiplayerRoomComposite public partial class RankRangePill : CompositeDrawable
{ {
private OsuTextFlowContainer rankFlow; private OsuTextFlowContainer rankFlow = null!;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
public RankRangePill() public RankRangePill()
{ {
@ -55,20 +57,28 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
}; };
} }
protected override void OnRoomUpdated() protected override void LoadComplete()
{ {
base.OnRoomUpdated(); base.LoadComplete();
client.RoomUpdated += onRoomUpdated;
updateState();
}
private void onRoomUpdated() => Scheduler.AddOnce(updateState);
private void updateState()
{
rankFlow.Clear(); rankFlow.Clear();
if (Room == null || Room.Users.All(u => u.User == null)) if (client.Room == null || client.Room.Users.All(u => u.User == null))
{ {
rankFlow.AddText("-"); rankFlow.AddText("-");
return; return;
} }
int minRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min(); int minRank = client.Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min();
int maxRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max(); int maxRank = client.Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max();
rankFlow.AddText("#"); rankFlow.AddText("#");
rankFlow.AddText(minRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold)); rankFlow.AddText(minRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold));
@ -78,5 +88,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
rankFlow.AddText("#"); rankFlow.AddText("#");
rankFlow.AddText(maxRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold)); rankFlow.AddText(maxRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.RoomUpdated -= onRoomUpdated;
}
} }
} }

View File

@ -1,47 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Audio.Sample; using osu.Framework.Audio.Sample;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Rooms;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Dialog;
using osuTK; using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{ {
public partial class MatchStartControl : MultiplayerRoomComposite public partial class MatchStartControl : CompositeDrawable
{ {
[Resolved] [Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; } private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
[CanBeNull]
private IDisposable clickOperation;
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private IDialogOverlay dialogOverlay { get; set; } private IDialogOverlay? dialogOverlay { get; set; }
private Sample sampleReady; [Resolved]
private Sample sampleReadyAll; private MultiplayerClient client { get; set; } = null!;
private Sample sampleUnready;
[Resolved]
private IBindable<PlaylistItem?> currentItem { get; set; } = null!;
private readonly MultiplayerReadyButton readyButton; private readonly MultiplayerReadyButton readyButton;
private readonly MultiplayerCountdownButton countdownButton; private readonly MultiplayerCountdownButton countdownButton;
private IBindable<bool> operationInProgress = null!;
private ScheduledDelegate? readySampleDelegate;
private IDisposable? clickOperation;
private Sample? sampleReady;
private Sample? sampleReadyAll;
private Sample? sampleUnready;
private int countReady; private int countReady;
private ScheduledDelegate readySampleDelegate;
private IBindable<bool> operationInProgress;
public MatchStartControl() public MatchStartControl()
{ {
@ -91,34 +94,29 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{ {
base.LoadComplete(); base.LoadComplete();
CurrentPlaylistItem.BindValueChanged(_ => updateState()); currentItem.BindValueChanged(_ => updateState());
} client.RoomUpdated += onRoomUpdated;
client.LoadRequested += onLoadRequested;
protected override void OnRoomUpdated()
{
base.OnRoomUpdated();
updateState(); updateState();
} }
protected override void OnRoomLoadRequested() private void onRoomUpdated() => Scheduler.AddOnce(updateState);
{
base.OnRoomLoadRequested(); private void onLoadRequested() => Scheduler.AddOnce(endOperation);
endOperation();
}
private void onReadyButtonClick() private void onReadyButtonClick()
{ {
if (Room == null) if (client.Room == null)
return; return;
Debug.Assert(clickOperation == null); Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation(); clickOperation = ongoingOperationTracker.BeginOperation();
if (Client.IsHost) if (client.IsHost)
{ {
if (Room.State == MultiplayerRoomState.Open) if (client.Room.State == MultiplayerRoomState.Open)
{ {
if (isReady() && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) if (isReady() && !client.Room.ActiveCountdowns.Any(c => c is MatchStartCountdown))
startMatch(); startMatch();
else else
toggleReady(); toggleReady();
@ -131,16 +129,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
dialogOverlay.Push(new ConfirmAbortDialog(abortMatch, endOperation)); dialogOverlay.Push(new ConfirmAbortDialog(abortMatch, endOperation));
} }
} }
else if (Room.State != MultiplayerRoomState.Closed) else if (client.Room.State != MultiplayerRoomState.Closed)
toggleReady(); toggleReady();
bool isReady() => Client.LocalUser?.State == MultiplayerUserState.Ready || Client.LocalUser?.State == MultiplayerUserState.Spectating; bool isReady() => client.LocalUser?.State == MultiplayerUserState.Ready || client.LocalUser?.State == MultiplayerUserState.Spectating;
void toggleReady() => Client.ToggleReady().FireAndForget( void toggleReady() => client.ToggleReady().FireAndForget(
onSuccess: endOperation, onSuccess: endOperation,
onError: _ => endOperation()); onError: _ => endOperation());
void startMatch() => Client.StartMatch().FireAndForget(onSuccess: () => void startMatch() => client.StartMatch().FireAndForget(onSuccess: () =>
{ {
// gameplay is starting, the button will be unblocked on load requested. // gameplay is starting, the button will be unblocked on load requested.
}, onError: _ => }, onError: _ =>
@ -149,7 +147,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
endOperation(); endOperation();
}); });
void abortMatch() => Client.AbortMatch().FireAndForget(endOperation, _ => endOperation()); void abortMatch() => client.AbortMatch().FireAndForget(endOperation, _ => endOperation());
} }
private void startCountdown(TimeSpan duration) private void startCountdown(TimeSpan duration)
@ -157,19 +155,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Debug.Assert(clickOperation == null); Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation(); clickOperation = ongoingOperationTracker.BeginOperation();
Client.SendMatchRequest(new StartMatchCountdownRequest { Duration = duration }).ContinueWith(_ => endOperation()); client.SendMatchRequest(new StartMatchCountdownRequest { Duration = duration }).ContinueWith(_ => endOperation());
} }
private void cancelCountdown() private void cancelCountdown()
{ {
if (Client.Room == null) if (client.Room == null)
return; return;
Debug.Assert(clickOperation == null); Debug.Assert(clickOperation == null);
clickOperation = ongoingOperationTracker.BeginOperation(); clickOperation = ongoingOperationTracker.BeginOperation();
MultiplayerCountdown countdown = Client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown); MultiplayerCountdown countdown = client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown);
Client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation()); client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation());
} }
private void endOperation() private void endOperation()
@ -180,19 +178,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void updateState() private void updateState()
{ {
if (Room == null) if (client.Room == null)
{ {
readyButton.Enabled.Value = false; readyButton.Enabled.Value = false;
countdownButton.Enabled.Value = false; countdownButton.Enabled.Value = false;
return; return;
} }
var localUser = Client.LocalUser; var localUser = client.LocalUser;
int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready); int newCountReady = client.Room.Users.Count(u => u.State == MultiplayerUserState.Ready);
int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating); int newCountTotal = client.Room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
if (!Client.IsHost || Room.Settings.AutoStartEnabled) if (!client.IsHost || client.Room.Settings.AutoStartEnabled)
countdownButton.Hide(); countdownButton.Hide();
else else
{ {
@ -211,21 +209,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
} }
readyButton.Enabled.Value = countdownButton.Enabled.Value = readyButton.Enabled.Value = countdownButton.Enabled.Value =
Room.State != MultiplayerRoomState.Closed client.Room.State != MultiplayerRoomState.Closed
&& CurrentPlaylistItem.Value?.ID == Room.Settings.PlaylistItemId && currentItem.Value?.ID == client.Room.Settings.PlaylistItemId
&& !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired && !client.Room.Playlist.Single(i => i.ID == client.Room.Settings.PlaylistItemId).Expired
&& !operationInProgress.Value; && !operationInProgress.Value;
// When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready.
if (localUser?.State == MultiplayerUserState.Spectating) if (localUser?.State == MultiplayerUserState.Spectating)
readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown); readyButton.Enabled.Value &= client.IsHost && newCountReady > 0 && !client.Room.ActiveCountdowns.Any(c => c is MatchStartCountdown);
// When the local user is not the host, the button should only be enabled when no match is in progress. // When the local user is not the host, the button should only be enabled when no match is in progress.
if (!Client.IsHost) if (!client.IsHost)
readyButton.Enabled.Value &= Room.State == MultiplayerRoomState.Open; readyButton.Enabled.Value &= client.Room.State == MultiplayerRoomState.Open;
// At all times, the countdown button should only be enabled when no match is in progress. // At all times, the countdown button should only be enabled when no match is in progress.
countdownButton.Enabled.Value &= Room.State == MultiplayerRoomState.Open; countdownButton.Enabled.Value &= client.Room.State == MultiplayerRoomState.Open;
if (newCountReady == countReady) if (newCountReady == countReady)
return; return;
@ -249,6 +247,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
}); });
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.RoomUpdated -= onRoomUpdated;
client.LoadRequested -= onLoadRequested;
}
}
public partial class ConfirmAbortDialog : DangerousActionDialog public partial class ConfirmAbortDialog : DangerousActionDialog
{ {
public ConfirmAbortDialog(Action abortMatch, Action cancel) public ConfirmAbortDialog(Action abortMatch, Action cancel)

View File

@ -5,7 +5,9 @@ using System.Threading;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Database; using osu.Game.Database;
@ -17,7 +19,7 @@ using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{ {
public partial class MultiplayerSpectateButton : MultiplayerRoomComposite public partial class MultiplayerSpectateButton : CompositeDrawable
{ {
[Resolved] [Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!;
@ -25,6 +27,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
[Resolved] [Resolved]
private OsuColour colours { get; set; } = null!; private OsuColour colours { get; set; } = null!;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private IBindable<PlaylistItem?> currentItem { get; set; } = null!;
private IBindable<bool> operationInProgress = null!; private IBindable<bool> operationInProgress = null!;
private readonly RoundedButton button; private readonly RoundedButton button;
@ -44,7 +52,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{ {
var clickOperation = ongoingOperationTracker.BeginOperation(); var clickOperation = ongoingOperationTracker.BeginOperation();
Client.ToggleSpectate().ContinueWith(_ => endOperation()); client.ToggleSpectate().ContinueWith(_ => endOperation());
void endOperation() => clickOperation?.Dispose(); void endOperation() => clickOperation?.Dispose();
} }
@ -63,19 +71,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{ {
base.LoadComplete(); base.LoadComplete();
CurrentPlaylistItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true); currentItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true);
} client.RoomUpdated += onRoomUpdated;
protected override void OnRoomUpdated()
{
base.OnRoomUpdated();
updateState(); updateState();
} }
private void onRoomUpdated() => Scheduler.AddOnce(updateState);
private void updateState() private void updateState()
{ {
switch (Client.LocalUser?.State) switch (client.LocalUser?.State)
{ {
default: default:
button.Text = "Spectate"; button.Text = "Spectate";
@ -88,8 +93,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
break; break;
} }
button.Enabled.Value = Client.Room != null button.Enabled.Value = client.Room != null
&& Client.Room.State != MultiplayerRoomState.Closed && client.Room.State != MultiplayerRoomState.Closed
&& !operationInProgress.Value; && !operationInProgress.Value;
Scheduler.AddOnce(checkForAutomaticDownload); Scheduler.AddOnce(checkForAutomaticDownload);
@ -112,11 +117,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void checkForAutomaticDownload() private void checkForAutomaticDownload()
{ {
PlaylistItem? currentItem = CurrentPlaylistItem.Value; PlaylistItem? item = currentItem.Value;
downloadCheckCancellation?.Cancel(); downloadCheckCancellation?.Cancel();
if (currentItem == null) if (item == null)
return; return;
if (!automaticallyDownload.Value) if (!automaticallyDownload.Value)
@ -128,13 +133,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
// //
// Rather than over-complicating this flow, let's only auto-download when spectating for the time being. // Rather than over-complicating this flow, let's only auto-download when spectating for the time being.
// A potential path forward would be to have a local auto-download checkbox above the playlist item list area. // A potential path forward would be to have a local auto-download checkbox above the playlist item list area.
if (Client.LocalUser?.State != MultiplayerUserState.Spectating) if (client.LocalUser?.State != MultiplayerUserState.Spectating)
return; return;
// In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes.
// ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised.
beatmapLookupCache beatmapLookupCache
.GetBeatmapAsync(currentItem.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token) .GetBeatmapAsync(item.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token)
.ContinueWith(resolved => Schedule(() => .ContinueWith(resolved => Schedule(() =>
{ {
var beatmapSet = resolved.GetResultSafely()?.BeatmapSet; var beatmapSet = resolved.GetResultSafely()?.BeatmapSet;
@ -150,5 +155,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
} }
#endregion #endregion
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.RoomUpdated -= onRoomUpdated;
}
} }
} }

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; using System;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -17,18 +15,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
/// <summary> /// <summary>
/// The multiplayer playlist, containing lists to show the items from a <see cref="MultiplayerRoom"/> in both gameplay-order and historical-order. /// The multiplayer playlist, containing lists to show the items from a <see cref="MultiplayerRoom"/> in both gameplay-order and historical-order.
/// </summary> /// </summary>
public partial class MultiplayerPlaylist : MultiplayerRoomComposite public partial class MultiplayerPlaylist : CompositeDrawable
{ {
public readonly Bindable<MultiplayerPlaylistDisplayMode> DisplayMode = new Bindable<MultiplayerPlaylistDisplayMode>(); public readonly Bindable<MultiplayerPlaylistDisplayMode> DisplayMode = new Bindable<MultiplayerPlaylistDisplayMode>();
/// <summary> /// <summary>
/// Invoked when an item requests to be edited. /// Invoked when an item requests to be edited.
/// </summary> /// </summary>
public Action<PlaylistItem> RequestEdit; public Action<PlaylistItem>? RequestEdit;
private MultiplayerPlaylistTabControl playlistTabControl; [Resolved]
private MultiplayerQueueList queueList; private MultiplayerClient client { get; set; } = null!;
private MultiplayerHistoryList historyList;
[Resolved]
private IBindable<PlaylistItem?> currentItem { get; set; } = null!;
private MultiplayerPlaylistTabControl playlistTabControl = null!;
private MultiplayerQueueList queueList = null!;
private MultiplayerHistoryList historyList = null!;
private bool firstPopulation = true; private bool firstPopulation = true;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
@ -54,14 +58,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
queueList = new MultiplayerQueueList queueList = new MultiplayerQueueList
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
SelectedItem = { BindTarget = CurrentPlaylistItem }, SelectedItem = { BindTarget = currentItem },
RequestEdit = item => RequestEdit?.Invoke(item) RequestEdit = item => RequestEdit?.Invoke(item)
}, },
historyList = new MultiplayerHistoryList historyList = new MultiplayerHistoryList
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Alpha = 0, Alpha = 0,
SelectedItem = { BindTarget = CurrentPlaylistItem } SelectedItem = { BindTarget = currentItem }
} }
} }
} }
@ -73,7 +77,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
DisplayMode.BindValueChanged(onDisplayModeChanged, true); DisplayMode.BindValueChanged(onDisplayModeChanged, true);
client.ItemAdded += playlistItemAdded;
client.ItemRemoved += playlistItemRemoved;
client.ItemChanged += playlistItemChanged;
client.RoomUpdated += onRoomUpdated;
updateState();
} }
private void onDisplayModeChanged(ValueChangedEvent<MultiplayerPlaylistDisplayMode> mode) private void onDisplayModeChanged(ValueChangedEvent<MultiplayerPlaylistDisplayMode> mode)
@ -82,11 +92,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
queueList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.Queue ? 1 : 0, 100); queueList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.Queue ? 1 : 0, 100);
} }
protected override void OnRoomUpdated() private void onRoomUpdated() => Scheduler.AddOnce(updateState);
{
base.OnRoomUpdated();
if (Room == null) private void updateState()
{
if (client.Room == null)
{ {
historyList.Items.Clear(); historyList.Items.Clear();
queueList.Items.Clear(); queueList.Items.Clear();
@ -96,34 +106,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
if (firstPopulation) if (firstPopulation)
{ {
foreach (var item in Room.Playlist) foreach (var item in client.Room.Playlist)
addItemToLists(item); addItemToLists(item);
firstPopulation = false; firstPopulation = false;
} }
} }
protected override void PlaylistItemAdded(MultiplayerPlaylistItem item) private void playlistItemAdded(MultiplayerPlaylistItem item) => Schedule(() => addItemToLists(item));
{
base.PlaylistItemAdded(item);
addItemToLists(item);
}
protected override void PlaylistItemRemoved(long item) private void playlistItemRemoved(long item) => Schedule(() => removeItemFromLists(item));
{
base.PlaylistItemRemoved(item);
removeItemFromLists(item);
}
protected override void PlaylistItemChanged(MultiplayerPlaylistItem item) private void playlistItemChanged(MultiplayerPlaylistItem item) => Schedule(() =>
{ {
base.PlaylistItemChanged(item); if (client.Room == null)
return;
var newApiItem = Playlist.SingleOrDefault(i => i.ID == item.ID); var newApiItem = new PlaylistItem(item);
var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID); var existingApiItemInQueue = queueList.Items.SingleOrDefault(i => i.ID == item.ID);
// Test if the only change between the two playlist items is the order. // Test if the only change between the two playlist items is the order.
if (newApiItem != null && existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem)) if (existingApiItemInQueue != null && existingApiItemInQueue.With(playlistOrder: newApiItem.PlaylistOrder).Equals(newApiItem))
{ {
// Set the new playlist order directly without refreshing the DrawablePlaylistItem. // Set the new playlist order directly without refreshing the DrawablePlaylistItem.
existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder; existingApiItemInQueue.PlaylistOrder = newApiItem.PlaylistOrder;
@ -137,20 +140,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist
removeItemFromLists(item.ID); removeItemFromLists(item.ID);
addItemToLists(item); addItemToLists(item);
} }
} });
private void addItemToLists(MultiplayerPlaylistItem item) private void addItemToLists(MultiplayerPlaylistItem item)
{ {
var apiItem = Playlist.SingleOrDefault(i => i.ID == item.ID); var apiItem = client.Room?.Playlist.SingleOrDefault(i => i.ID == item.ID);
// Item could have been removed from the playlist while the local player was in gameplay. // Item could have been removed from the playlist while the local player was in gameplay.
if (apiItem == null) if (apiItem == null)
return; return;
if (item.Expired) if (item.Expired)
historyList.Items.Add(apiItem); historyList.Items.Add(new PlaylistItem(apiItem));
else else
queueList.Items.Add(apiItem); queueList.Items.Add(new PlaylistItem(apiItem));
} }
private void removeItemFromLists(long item) private void removeItemFromLists(long item)

View File

@ -1,125 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
public abstract partial class MultiplayerRoomComposite : OnlinePlayComposite
{
[CanBeNull]
protected MultiplayerRoom Room => Client.Room;
[Resolved]
protected MultiplayerClient Client { get; private set; }
protected override void LoadComplete()
{
base.LoadComplete();
Client.RoomUpdated += invokeOnRoomUpdated;
Client.LoadRequested += invokeOnRoomLoadRequested;
Client.UserLeft += invokeUserLeft;
Client.UserKicked += invokeUserKicked;
Client.UserJoined += invokeUserJoined;
Client.ItemAdded += invokeItemAdded;
Client.ItemRemoved += invokeItemRemoved;
Client.ItemChanged += invokeItemChanged;
OnRoomUpdated();
}
private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated);
private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user));
private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user));
private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user));
private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item));
private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item));
private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item));
private void invokeOnRoomLoadRequested() => Scheduler.AddOnce(OnRoomLoadRequested);
/// <summary>
/// Invoked when a user has joined the room.
/// </summary>
/// <param name="user">The user.</param>
protected virtual void UserJoined(MultiplayerRoomUser user)
{
}
/// <summary>
/// Invoked when a user has been kicked from the room (including the local user).
/// </summary>
/// <param name="user">The user.</param>
protected virtual void UserKicked(MultiplayerRoomUser user)
{
}
/// <summary>
/// Invoked when a user has left the room.
/// </summary>
/// <param name="user">The user.</param>
protected virtual void UserLeft(MultiplayerRoomUser user)
{
}
/// <summary>
/// Invoked when a playlist item is added to the room.
/// </summary>
/// <param name="item">The added playlist item.</param>
protected virtual void PlaylistItemAdded(MultiplayerPlaylistItem item)
{
}
/// <summary>
/// Invoked when a playlist item is removed from the room.
/// </summary>
/// <param name="item">The ID of the removed playlist item.</param>
protected virtual void PlaylistItemRemoved(long item)
{
}
/// <summary>
/// Invoked when a playlist item is changed in the room.
/// </summary>
/// <param name="item">The new playlist item, with an existing item's ID.</param>
protected virtual void PlaylistItemChanged(MultiplayerPlaylistItem item)
{
}
/// <summary>
/// Invoked when any change occurs to the multiplayer room.
/// </summary>
protected virtual void OnRoomUpdated()
{
}
/// <summary>
/// Invoked when the room requests the local user to load into gameplay.
/// </summary>
protected virtual void OnRoomLoadRequested()
{
}
protected override void Dispose(bool isDisposing)
{
if (Client != null)
{
Client.RoomUpdated -= invokeOnRoomUpdated;
Client.LoadRequested -= invokeOnRoomLoadRequested;
Client.UserLeft -= invokeUserLeft;
Client.UserKicked -= invokeUserKicked;
Client.UserJoined -= invokeUserJoined;
Client.ItemAdded -= invokeItemAdded;
Client.ItemRemoved -= invokeItemRemoved;
Client.ItemChanged -= invokeItemChanged;
}
base.Dispose(isDisposing);
}
}
}

View File

@ -1,23 +1,26 @@
// 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.Collections.Generic;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Audio.Sample; using osu.Framework.Audio.Sample;
using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Online.API.Requests.Responses; using osu.Framework.Graphics.Containers;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
namespace osu.Game.Screens.OnlinePlay.Multiplayer namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
public partial class MultiplayerRoomSounds : MultiplayerRoomComposite public partial class MultiplayerRoomSounds : CompositeDrawable
{ {
private Sample hostChangedSample; [Resolved]
private Sample userJoinedSample; private MultiplayerClient client { get; set; } = null!;
private Sample userLeftSample;
private Sample userKickedSample; private Sample? hostChangedSample;
private Sample? userJoinedSample;
private Sample? userLeftSample;
private Sample? userKickedSample;
private MultiplayerRoomUser? host;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(AudioManager audio) private void load(AudioManager audio)
@ -32,36 +35,47 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
base.LoadComplete(); base.LoadComplete();
Host.BindValueChanged(hostChanged); client.RoomUpdated += onRoomUpdated;
client.UserJoined += onUserJoined;
client.UserLeft += onUserLeft;
client.UserKicked += onUserKicked;
updateState();
} }
protected override void UserJoined(MultiplayerRoomUser user) private void onRoomUpdated() => Scheduler.AddOnce(updateState);
private void updateState()
{ {
base.UserJoined(user); if (EqualityComparer<MultiplayerRoomUser>.Default.Equals(host, client.Room?.Host))
return;
Scheduler.AddOnce(() => userJoinedSample?.Play());
}
protected override void UserLeft(MultiplayerRoomUser user)
{
base.UserLeft(user);
Scheduler.AddOnce(() => userLeftSample?.Play());
}
protected override void UserKicked(MultiplayerRoomUser user)
{
base.UserKicked(user);
Scheduler.AddOnce(() => userKickedSample?.Play());
}
private void hostChanged(ValueChangedEvent<APIUser> value)
{
// only play sound when the host changes from an already-existing host. // only play sound when the host changes from an already-existing host.
if (value.OldValue == null) return; if (host != null)
Scheduler.AddOnce(() => hostChangedSample?.Play()); Scheduler.AddOnce(() => hostChangedSample?.Play());
host = client.Room?.Host;
}
private void onUserJoined(MultiplayerRoomUser user)
=> Scheduler.AddOnce(() => userJoinedSample?.Play());
private void onUserLeft(MultiplayerRoomUser user)
=> Scheduler.AddOnce(() => userLeftSample?.Play());
private void onUserKicked(MultiplayerRoomUser user)
=> Scheduler.AddOnce(() => userKickedSample?.Play());
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
{
client.RoomUpdated -= onRoomUpdated;
client.UserJoined -= onUserJoined;
client.UserLeft -= onUserLeft;
client.UserKicked -= onUserKicked;
}
} }
} }
} }

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -30,7 +31,7 @@ using osuTK.Graphics;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{ {
public partial class ParticipantPanel : MultiplayerRoomComposite, IHasContextMenu public partial class ParticipantPanel : CompositeDrawable, IHasContextMenu
{ {
public readonly MultiplayerRoomUser User; public readonly MultiplayerRoomUser User;
@ -40,6 +41,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
[Resolved] [Resolved]
private IRulesetStore rulesets { get; set; } = null!; private IRulesetStore rulesets { get; set; } = null!;
[Resolved]
private MultiplayerClient client { get; set; } = null!;
private SpriteIcon crown = null!; private SpriteIcon crown = null!;
private OsuSpriteText userRankText = null!; private OsuSpriteText userRankText = null!;
@ -171,23 +175,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Origin = Anchor.Centre, Origin = Anchor.Centre,
Alpha = 0, Alpha = 0,
Margin = new MarginPadding(4), Margin = new MarginPadding(4),
Action = () => Client.KickUser(User.UserID).FireAndForget(), Action = () => client.KickUser(User.UserID).FireAndForget(),
}, },
}, },
} }
}; };
} }
protected override void OnRoomUpdated() protected override void LoadComplete()
{ {
base.OnRoomUpdated(); base.LoadComplete();
if (Room == null || Client.LocalUser == null) client.RoomUpdated += onRoomUpdated;
updateState();
}
private void onRoomUpdated() => Scheduler.AddOnce(updateState);
private void updateState()
{
if (client.Room == null || client.LocalUser == null)
return; return;
const double fade_time = 50; const double fade_time = 50;
var currentItem = Playlist.GetCurrentItem(); MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem();
Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null;
int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null;
@ -200,8 +212,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
else else
userModsDisplay.FadeOut(fade_time); userModsDisplay.FadeOut(fade_time);
kickButton.Alpha = Client.IsHost && !User.Equals(Client.LocalUser) ? 1 : 0; kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0;
crown.Alpha = Room.Host?.Equals(User) == true ? 1 : 0; crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0;
// If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187
// This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix.
@ -215,7 +227,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{ {
get get
{ {
if (Room == null) if (client.Room == null)
return null; return null;
// If the local user is targetted. // If the local user is targetted.
@ -223,7 +235,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
return null; return null;
// If the local user is not the host of the room. // If the local user is not the host of the room.
if (Room.Host?.UserID != api.LocalUser.Value.Id) if (client.Room.Host?.UserID != api.LocalUser.Value.Id)
return null; return null;
int targetUser = User.UserID; int targetUser = User.UserID;
@ -233,23 +245,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
new OsuMenuItem("Give host", MenuItemType.Standard, () => new OsuMenuItem("Give host", MenuItemType.Standard, () =>
{ {
// Ensure the local user is still host. // Ensure the local user is still host.
if (!Client.IsHost) if (!client.IsHost)
return; return;
Client.TransferHost(targetUser).FireAndForget(); client.TransferHost(targetUser).FireAndForget();
}), }),
new OsuMenuItem("Kick", MenuItemType.Destructive, () => new OsuMenuItem("Kick", MenuItemType.Destructive, () =>
{ {
// Ensure the local user is still host. // Ensure the local user is still host.
if (!Client.IsHost) if (!client.IsHost)
return; return;
Client.KickUser(targetUser).FireAndForget(); client.KickUser(targetUser).FireAndForget();
}) })
}; };
} }
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.RoomUpdated -= onRoomUpdated;
}
public partial class KickButton : IconButton public partial class KickButton : IconButton
{ {
public KickButton() public KickButton()

View File

@ -1,24 +1,24 @@
// 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 JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Online.Multiplayer;
using osuTK; using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{ {
public partial class ParticipantsList : MultiplayerRoomComposite public partial class ParticipantsList : CompositeDrawable
{ {
private FillFlowContainer<ParticipantPanel> panels; private FillFlowContainer<ParticipantPanel> panels = null!;
private ParticipantPanel? currentHostPanel;
[CanBeNull] [Resolved]
private ParticipantPanel currentHostPanel; private MultiplayerClient client { get; set; } = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
@ -37,11 +37,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
}; };
} }
protected override void OnRoomUpdated() protected override void LoadComplete()
{ {
base.OnRoomUpdated(); base.LoadComplete();
if (Room == null) client.RoomUpdated += onRoomUpdated;
updateState();
}
private void onRoomUpdated() => Scheduler.AddOnce(updateState);
private void updateState()
{
if (client.Room == null)
panels.Clear(); panels.Clear();
else else
{ {
@ -49,15 +57,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
foreach (var p in panels) foreach (var p in panels)
{ {
// Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run.
if (Room.Users.All(u => !ReferenceEquals(p.User, u))) if (client.Room.Users.All(u => !ReferenceEquals(p.User, u)))
p.Expire(); p.Expire();
} }
// Add panels for all users new to the room. // Add panels for all users new to the room.
foreach (var user in Room.Users.Except(panels.Select(p => p.User))) foreach (var user in client.Room.Users.Except(panels.Select(p => p.User)))
panels.Add(new ParticipantPanel(user)); panels.Add(new ParticipantPanel(user));
if (currentHostPanel == null || !currentHostPanel.User.Equals(Room.Host)) if (currentHostPanel == null || !currentHostPanel.User.Equals(client.Room.Host))
{ {
// Reset position of previous host back to normal, if one existing. // Reset position of previous host back to normal, if one existing.
if (currentHostPanel != null && panels.Contains(currentHostPanel)) if (currentHostPanel != null && panels.Contains(currentHostPanel))
@ -66,9 +74,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
currentHostPanel = null; currentHostPanel = null;
// Change position of new host to display above all participants. // Change position of new host to display above all participants.
if (Room.Host != null) if (client.Room.Host != null)
{ {
currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(Room.Host)); currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(client.Room.Host));
if (currentHostPanel != null) if (currentHostPanel != null)
panels.SetLayoutPosition(currentHostPanel, -1); panels.SetLayoutPosition(currentHostPanel, -1);
@ -76,5 +84,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
} }
} }
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.RoomUpdated -= onRoomUpdated;
}
} }
} }

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