1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-14 04:22:34 +08:00

Compare commits

...

30 Commits

119 changed files with 1431 additions and 1316 deletions
+8 -11
View File
@@ -3,28 +3,25 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2023.3.3",
"version": "2025.2.3",
"commands": [
"jb"
]
},
"nvika": {
"version": "4.0.0",
"commands": [
"nvika"
]
],
"rollForward": false
},
"codefilesanity": {
"version": "0.0.37",
"commands": [
"CodeFileSanity"
]
],
"rollForward": false
},
"ppy.localisationanalyser.tools": {
"version": "2025.1208.0",
"commands": [
"localisation"
]
],
"rollForward": false
}
}
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
path: ${{ inputs.id }}
repository: 'smoogipoo/diffcalc-sheet-generator'
+20 -15
View File
@@ -6,6 +6,7 @@ concurrency:
permissions:
contents: read # to fetch code (actions/checkout)
security-events: write # for reporting InspectCode issues
jobs:
inspect-code:
@@ -13,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -27,7 +28,7 @@ jobs:
run: dotnet restore osu.Desktop.slnf
- name: Restore inspectcode cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ github.workspace }}/inspectcode
key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', 'osu*.slnf', '.editorconfig', '.globalconfig', 'CodeAnalysis/*', '**/*.csproj', '**/*.props') }}
@@ -49,10 +50,14 @@ jobs:
exit $exit_code
- name: InspectCode
run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
- name: NVika
run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors
uses: JetBrains/ReSharper-InspectCode@v0.11
with:
# this is WTF tier but if you don't specify *both* of these the defaults assume `build: true`
build: false
no-build: true
solution: ./osu.Desktop.slnf
caches-home: inspectcode
verbosity: WARN
test:
name: Test
@@ -71,10 +76,10 @@ jobs:
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -98,7 +103,7 @@ jobs:
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- name: Upload Test Results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: ${{ always() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
@@ -110,16 +115,16 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup JDK 11
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: microsoft
java-version: 11
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -135,10 +140,10 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
+5 -4
View File
@@ -3,7 +3,8 @@ name: Pack and nuget
on:
push:
tags:
- '*'
- '*.*.*'
- '!*-*'
jobs:
notify_pending_production_deploy:
@@ -43,14 +44,14 @@ jobs:
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set artifacts directory
id: artifactsPath
run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts"
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
@@ -76,7 +77,7 @@ jobs:
dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: osu
path: |
+3 -3
View File
@@ -22,13 +22,13 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: ${{ github.event.workflow_run.repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Download results
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: osu-test-results-*
merge-multiple: true
@@ -36,7 +36,7 @@ jobs:
github-token: ${{ github.token }}
- name: Annotate CI run with test results
uses: dorny/test-reporter@v1.8.0
uses: dorny/test-reporter@v2.6.0
with:
name: Results
path: "*.trx"
+2 -2
View File
@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Create Sentry release
uses: getsentry/action-release@v1
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ppy
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.310.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.318.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+1
View File
@@ -58,6 +58,7 @@ namespace osu.Desktop
private readonly RichPresence presence = new RichPresence
{
Assets = new Assets { LargeImageKey = "osu_logo_lazer" },
Timestamps = Timestamps.Now,
Secrets = new Secrets
{
JoinSecret = null,
@@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
namespace osu.Desktop.MacOS
{
/// <summary>
/// Checks if the game is located at `Applications` folder and displays a warning notification if not so.
/// </summary>
public partial class MacOSAppLocationChecker : Component
{
[Resolved]
private INotificationOverlay notification { get; set; } = null!;
protected override void LoadComplete()
{
base.LoadComplete();
string assemblyPath = RuntimeInfo.EntryAssembly.Location;
bool inRootApp = assemblyPath.StartsWith("/Applications/", StringComparison.Ordinal);
bool inUserApp = assemblyPath.StartsWith(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications/"), StringComparison.Ordinal);
if (!inRootApp && !inUserApp)
notification.Post(new MacOSAppLocationNotification());
Expire();
}
private partial class MacOSAppLocationNotification : SimpleNotification
{
public MacOSAppLocationNotification()
{
Text = NotificationsStrings.MacOSAppLocation(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Icon = FontAwesome.Solid.ShieldAlt;
IconContent.Colour = colours.YellowDark;
}
}
}
}
+13 -5
View File
@@ -5,7 +5,6 @@ using System;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using Microsoft.Win32;
using osu.Desktop.Performance;
using osu.Desktop.Security;
@@ -15,12 +14,12 @@ using osu.Desktop.Updater;
using osu.Framework;
using osu.Framework.Logging;
using osu.Game.Updater;
using osu.Desktop.MacOS;
using osu.Desktop.Windows;
using osu.Framework.Allocation;
using osu.Game.Configuration;
using osu.Game.IO;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
using osu.Game.Performance;
using osu.Game.Utils;
@@ -123,7 +122,7 @@ namespace osu.Desktop
public override bool RestartAppWhenExited()
{
Task.Run(() => Velopack.UpdateExe.Start(waitPid: (uint)Environment.ProcessId)).FireAndForget();
RestartOnExitAction = () => Velopack.UpdateExe.Start(waitPid: (uint)Environment.ProcessId);
return true;
}
@@ -133,8 +132,17 @@ namespace osu.Desktop
LoadComponentAsync(new DiscordRichPresence(), Add);
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
break;
case RuntimeInfo.Platform.macOS when !IsPackageManaged && IsDeployedBuild:
if (!IsPackageManaged && IsDeployedBuild)
LoadComponentAsync(new MacOSAppLocationChecker(), Add);
break;
}
LoadComponentAsync(new ElevatedPrivilegesChecker(), Add);
@@ -27,6 +27,8 @@ namespace osu.Desktop.Security
if (Environment.IsPrivilegedProcess)
notifications.Post(new ElevatedPrivilegesNotification());
Expire();
}
private partial class ElevatedPrivilegesNotification : SimpleNotification
+4 -4
View File
@@ -146,11 +146,11 @@ namespace osu.Desktop.Updater
action();
}
private void restartToApplyUpdate(Velopack.UpdateManager updateManager, UpdateInfo update) => Task.Run(async () =>
private void restartToApplyUpdate(Velopack.UpdateManager updateManager, UpdateInfo update)
{
await updateManager.WaitExitThenApplyUpdatesAsync(update.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
});
game.RestartOnExitAction = () => updateManager.WaitExitThenApplyUpdates(update.TargetFullRelease);
game.AttemptExit();
}
private static void log(string text) => Logger.Log($"VelopackUpdateManager: {text}");
}
@@ -193,20 +193,20 @@ namespace osu.Game.Rulesets.Catch.Tests
{
public Color4 HyperDashColour
{
get => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()];
set => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()] = value;
get => Configuration.CustomColours[nameof(CatchSkinColour.HyperDash)];
set => Configuration.CustomColours[nameof(CatchSkinColour.HyperDash)] = value;
}
public Color4 HyperDashAfterImageColour
{
get => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()];
set => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()] = value;
get => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashAfterImage)];
set => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashAfterImage)] = value;
}
public Color4 HyperDashFruitColour
{
get => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()];
set => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()] = value;
get => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashFruit)];
set => Configuration.CustomColours[nameof(CatchSkinColour.HyperDashFruit)] = value;
}
public TestSkin()
@@ -10,6 +10,7 @@ using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Objects;
@@ -505,6 +506,29 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit()));
}
/// <summary>
/// This ensures that the value of <see cref="DrawableHoldNote.MissingStartTime"/>
/// will be set correctly when the body receives a judgment during the hold.
///
/// -----[ ]-----
/// x o
/// </summary>
[Test]
public void TestReleaseDuringHoldMissingStartTime()
{
performTest([
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_during_hold_1)
]);
assertHeadJudgement(HitResult.Perfect);
assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss);
AddAssert("body judgement is miss", () => !judgementResults.Single(j => j.HitObject is HoldNoteBody).IsHit);
AddAssert("body judgement time indicates during hold", () => judgementResults.Single(j => j.HitObject is HoldNoteBody).TimeAbsolute, () => Is.EqualTo(time_during_hold_1).Within(100));
}
private void assertHitObjectJudgement(HitObject hitObject, HitResult result)
=> AddAssert($"object judged as {result}", () => judgementResults.First(j => j.HitObject == hitObject).Type, () => Is.EqualTo(result));
@@ -31,10 +31,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public override bool DisplayResult => false;
/// <summary>
/// Whether the user is currently pressing the hold note.
/// </summary>
public IBindable<bool> IsHolding => isHolding;
private readonly Bindable<bool> isHolding = new Bindable<bool>();
/// <summary>
/// The time at which the user starting missing the hold note.
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
/// </summary>
public IBindable<double?> MissingStartTime => missingStartTime;
private readonly Bindable<double?> missingStartTime = new Bindable<double?>();
public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child;
public DrawableHoldNoteBody Body => bodyContainer.Child;
@@ -197,11 +208,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public override void OnKilled()
{
base.OnKilled();
// flush the final state of holding on kill.
// this matters because some skin implementations like legacy skin
// insert drawables in the hierarchy that are not a child of this DHO
// (see `LegacyBodyPiece` and related machinations with `lightContainer` being added at column level)
isHolding.Value = Result.IsHolding(Time.Current);
missingStartTime.Value = null;
(bodyPiece.Drawable as IHoldNoteBody)?.Recycle();
}
@@ -209,6 +222,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
base.Update();
if (Head.Judged && !Head.IsHit)
missingStartTime.Value ??= Head.Result.TimeAbsolute;
if (Body.HasHoldBreak)
missingStartTime.Value ??= Body.Result.TimeAbsolute;
if (Tail.Judged && !Tail.IsHit)
missingStartTime.Value ??= Tail.Result.TimeAbsolute;
isHolding.Value = Result.IsHolding(Time.Current);
// Pad the full size container so its contents (i.e. the masking container) reach under the tail.
@@ -3,6 +3,7 @@
#nullable disable
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects.Drawables;
@@ -14,6 +15,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary>
public partial class DrawableHoldNoteHead : DrawableNote
{
/// <summary>
/// The time at which the user starting missing the hold note.
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
/// </summary>
public readonly IBindable<double?> MissingStartTime = new Bindable<double?>();
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead;
public DrawableHoldNoteHead()
@@ -28,6 +35,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Origin = Anchor.TopCentre;
}
protected override void OnApply()
{
base.OnApply();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.BindTo(parentHold.MissingStartTime);
}
protected override void OnFree()
{
base.OnFree();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.UnbindFrom(parentHold.MissingStartTime);
}
public bool UpdateResult() => base.UpdateResult(true);
protected override void UpdateHitStateTransforms(ArmedState state)
@@ -3,6 +3,7 @@
#nullable disable
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Scoring;
@@ -14,6 +15,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
/// </summary>
public partial class DrawableHoldNoteTail : DrawableNote
{
/// <summary>
/// The time at which the user starting missing the hold note.
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
/// </summary>
public readonly IBindable<double?> MissingStartTime = new Bindable<double?>();
protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail;
protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
@@ -30,6 +37,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
Origin = Anchor.TopCentre;
}
protected override void OnApply()
{
base.OnApply();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.BindTo(parentHold.MissingStartTime);
}
protected override void OnFree()
{
base.OnFree();
if (ParentHitObject is DrawableHoldNote parentHold)
MissingStartTime.UnbindFrom(parentHold.MissingStartTime);
}
public void UpdateResult() => base.UpdateResult(true);
protected override void CheckForResult(bool userTriggered, double timeOffset) =>
+2 -1
View File
@@ -118,7 +118,8 @@ namespace osu.Game.Rulesets.Mania.Objects
AddNested(Body = new HoldNoteBody
{
StartTime = StartTime,
Column = Column
Column = Column,
Duration = Duration
});
}
@@ -3,6 +3,7 @@
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
@@ -13,9 +14,11 @@ namespace osu.Game.Rulesets.Mania.Objects
/// On hit - the hold note was held correctly for the full duration.<br />
/// On miss - the hold note was released at some point during its judgement period.
/// </summary>
public class HoldNoteBody : ManiaHitObject
public class HoldNoteBody : ManiaHitObject, IHasDuration
{
public override Judgement CreateJudgement() => new HoldNoteBodyJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
public double Duration { get; set; }
public double EndTime => StartTime + Duration;
}
}
@@ -25,17 +25,10 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
private readonly IBindable<bool> isHitting = new Bindable<bool>();
/// <summary>
/// Stores the start time of the fade animation that plays when any of the nested
/// hitobjects of the hold note are missed.
/// </summary>
private readonly Bindable<double?> missFadeTime = new Bindable<double?>();
private readonly IBindable<double?> missingStartTime = new Bindable<double?>();
private Drawable? bodySprite;
private Drawable? lightContainer;
private Drawable? light;
private LegacyNoteBodyStyle? bodyStyle;
@@ -87,6 +80,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
direction.BindTo(scrollingInfo.Direction);
isHitting.BindTo(holdNote.IsHolding);
missingStartTime.BindTo(holdNote.MissingStartTime);
bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30)?.With(d =>
{
@@ -109,26 +103,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
direction.BindValueChanged(onDirectionChanged, true);
isHitting.BindValueChanged(onIsHittingChanged, true);
missFadeTime.BindValueChanged(onMissFadeTimeChanged, true);
missingStartTime.BindValueChanged(onMissingStartTimeChanged, true);
holdNote.ApplyCustomUpdateState += applyCustomUpdateState;
applyCustomUpdateState(holdNote, holdNote.State.Value);
}
private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state)
{
switch (hitObject)
{
// Ensure that the hold note is also faded out when the head/tail/body is missed.
// Importantly, we filter out unrelated objects like DrawableNotePerfectBonus.
case DrawableHoldNoteTail:
case DrawableHoldNoteHead:
case DrawableHoldNoteBody:
if (state == ArmedState.Miss)
missFadeTime.Value ??= hitObject.HitStateUpdateTime;
break;
}
holdNote.ApplyCustomUpdateState += onApplyCustomUpdateState;
}
private void onIsHittingChanged(ValueChangedEvent<bool> isHitting)
@@ -187,23 +164,19 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
}
}
private void onMissFadeTimeChanged(ValueChangedEvent<double?> missFadeTimeChange)
private void onMissingStartTimeChanged(ValueChangedEvent<double?> startTime)
=> applyMissingDim();
private void onApplyCustomUpdateState(DrawableHitObject obj, ArmedState state)
=> applyMissingDim();
private void applyMissingDim()
{
if (missFadeTimeChange.NewValue == null)
if (missingStartTime.Value == null)
return;
// this update could come from any nested object of the hold note (or even from an input).
// make sure the transforms are consistent across all affected parts.
using (BeginAbsoluteSequence(missFadeTimeChange.NewValue.Value))
{
// colour and duration matches stable
// transforms not applied to entire hold note in order to not affect hit lighting
const double fade_duration = 60;
holdNote.Head.FadeColour(Colour4.DarkGray, fade_duration);
holdNote.Tail.FadeColour(Colour4.DarkGray, fade_duration);
bodySprite?.FadeColour(Colour4.DarkGray, fade_duration);
}
using (BeginAbsoluteSequence(missingStartTime.Value.Value))
this.FadeColour(Colour4.DarkGray, 60);
}
protected override void Update()
@@ -213,9 +186,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (!isHitting.Value)
(bodySprite as TextureAnimation)?.GotoFrame(0);
if (holdNote.Body.HasHoldBreak)
missFadeTime.Value = holdNote.Body.Result.TimeAbsolute;
int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1);
// here we go...
@@ -251,7 +221,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
base.Dispose(isDisposing);
if (holdNote.IsNotNull())
holdNote.ApplyCustomUpdateState -= applyCustomUpdateState;
holdNote.ApplyCustomUpdateState -= onApplyCustomUpdateState;
lightContainer?.Expire();
}
@@ -1,18 +1,61 @@
// 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.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
public partial class LegacyHoldNoteHeadPiece : LegacyNotePiece
{
private readonly IBindable<double?> missingStartTime = new Bindable<double?>();
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
protected override void LoadComplete()
{
base.LoadComplete();
missingStartTime.BindTo(((DrawableHoldNoteHead)drawableObject).MissingStartTime);
missingStartTime.BindValueChanged(onMissingStartTimeChanged, true);
drawableObject.ApplyCustomUpdateState += onApplyCustomUpdateState;
}
private void onMissingStartTimeChanged(ValueChangedEvent<double?> startTime)
=> applyMissingDim();
private void onApplyCustomUpdateState(DrawableHitObject obj, ArmedState state)
=> applyMissingDim();
private void applyMissingDim()
{
if (missingStartTime.Value == null)
return;
using (BeginAbsoluteSequence(missingStartTime.Value.Value))
this.FadeColour(Colour4.DarkGray, 60);
}
protected override Drawable? GetAnimation(ISkinSource skin)
{
// TODO: Should fallback to the head from default legacy skin instead of note.
return GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage)
?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= onApplyCustomUpdateState;
}
}
}
@@ -1,8 +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.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning;
@@ -10,6 +14,36 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
{
public partial class LegacyHoldNoteTailPiece : LegacyNotePiece
{
private readonly IBindable<double?> missingStartTime = new Bindable<double?>();
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
protected override void LoadComplete()
{
base.LoadComplete();
missingStartTime.BindTo(((DrawableHoldNoteTail)drawableObject).MissingStartTime);
missingStartTime.BindValueChanged(onMissingStartTimeChanged, true);
drawableObject.ApplyCustomUpdateState += onApplyCustomUpdateState;
}
private void onMissingStartTimeChanged(ValueChangedEvent<double?> startTime)
=> applyMissingDim();
private void onApplyCustomUpdateState(DrawableHitObject obj, ArmedState state)
=> applyMissingDim();
private void applyMissingDim()
{
if (missingStartTime.Value == null)
return;
using (BeginAbsoluteSequence(missingStartTime.Value.Value))
this.FadeColour(Colour4.DarkGray, 60);
}
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
// Invert the direction
@@ -25,5 +59,13 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage)
?? GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= onApplyCustomUpdateState;
}
}
}
@@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
Vector2 oldStartPos = default;
Vector2 oldEndPos = default;
double oldDistance = default;
double oldDistance = 0;
var oldControlPointTypes = controlPoints.Select(p => p.Type);
AddStep("Add slider", () =>
@@ -255,7 +255,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
Vector2 oldStartPos = default;
Vector2 oldEndPos = default;
double oldDistance = default;
double oldDistance = 0;
var oldControlPointTypes = segmentedSliderPath.Select(p => p.Type);
@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using osu.Framework.Allocation;
@@ -24,6 +26,36 @@ namespace osu.Game.Tests.Beatmaps.IO
[Resolved]
private BeatmapManager beatmaps { get; set; } = null!;
[TestCase(96, 32)]
[TestCase(32, 32)]
[TestCase(24, 32)]
[TestCase(23, 16)]
[TestCase(15, 16)]
[TestCase(12, 16)]
[TestCase(11, 8)]
[TestCase(6, 8)]
[TestCase(2, 4)]
public void TestGridSizeClampedToStableValues(int set, int expected)
{
IWorkingBeatmap beatmap = null!;
MemoryStream outStream = null!;
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"decimal-timing-beatmap.olz"));
AddStep("adjust grid", () => beatmap.Beatmap.GridSize = set);
AddStep("save", () => beatmaps.Save((beatmap.BeatmapInfo as BeatmapInfo)!, beatmap.Beatmap));
AddStep("export", () =>
{
outStream = new MemoryStream();
new LegacyBeatmapExporter(LocalStorage)
.ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
});
AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
AddAssert("grid clamped", () => beatmap.Beatmap.GridSize, () => Is.EqualTo(expected));
}
[Test]
public void TestObjectsSnappedAfterTruncatingExport()
{
@@ -131,6 +163,48 @@ namespace osu.Game.Tests.Beatmaps.IO
}
}
[Test]
public void TestExportUsesCarriageReturnLineFeed()
{
IWorkingBeatmap beatmap = null!;
MemoryStream outStream = null!;
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"legacy-export-stability-test.olz"));
AddStep("export", () =>
{
outStream = new MemoryStream();
new LegacyBeatmapExporter(LocalStorage)
.ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
});
AddAssert(".osu file uses CRLF line endings",
() => hasBareLineFeed(outStream.GetBuffer()),
() => Is.False);
bool hasBareLineFeed(byte[] archiveBytes)
{
using var memoryStream = new MemoryStream(archiveBytes);
using var archiveReader = new ZipArchiveReader(memoryStream);
foreach (string filename in archiveReader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.Ordinal)))
{
byte[] content = archiveReader.GetStream(filename).ReadAllBytesToArray();
for (int i = 0; i < content.Length; i++)
{
if (content[i] != '\n')
continue;
if (i == 0 || content[i - 1] != '\r')
return true;
}
}
return false;
}
}
private IWorkingBeatmap importBeatmapFromStream(Stream stream)
{
var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely();
@@ -258,6 +258,6 @@ namespace osu.Game.Tests.Gameplay
}
private void disableLayeredHitSounds()
=> AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[LegacySetting.LayeredHitSounds.ToString()] = "0");
=> AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[nameof(LegacySetting.LayeredHitSounds)] = "0");
}
}
@@ -219,6 +219,46 @@ namespace osu.Game.Tests.NonVisual.Filtering
ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
[TestCase("\"quoted words\"", false)]
[TestCase("\"the artist\"", false)]
[TestCase("the artist \"quoted words\"", false)]
[TestCase("\"unknown\"", true)]
public void TestCriteriaMatchingTermsAdjacentToPunctuation(string terms, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
exampleBeatmapInfo.Metadata.Title = "the artist \"quoted words\"";
var criteria = new FilterCriteria
{
Ruleset = new RulesetInfo { OnlineID = 6 },
AllowConvertedBeatmaps = true,
SearchText = terms
};
var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
carouselItem.Filter(criteria);
ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
[TestCase("~quoted words~", false)]
[TestCase("the artist", false)]
[TestCase("the artist ~quoted words~", false)]
[TestCase("~unknown~", true)]
public void TestCriteriaMatchingTermsAdjacentToMathSymbols(string terms, bool filtered)
{
var exampleBeatmapInfo = getExampleBeatmap();
exampleBeatmapInfo.Metadata.Title = "the artist ~quoted words~";
var criteria = new FilterCriteria
{
Ruleset = new RulesetInfo { OnlineID = 6 },
AllowConvertedBeatmaps = true,
SearchText = terms
};
var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
carouselItem.Filter(criteria);
ClassicAssert.AreEqual(filtered, carouselItem.Filtered.Value);
}
[Test]
[TestCase("", false)]
[TestCase("Goes", false)]
@@ -25,6 +25,7 @@ namespace osu.Game.Tests.Visual.Components
private NotificationOverlay notificationOverlay = null!;
private ChatOverlay chatOverlay = null!;
private TestMetadataClient metadataClient = null!;
private FriendPresenceNotifier notifier = null!;
[SetUp]
public void Setup() => Schedule(() =>
@@ -45,7 +46,11 @@ namespace osu.Game.Tests.Visual.Components
notificationOverlay,
chatOverlay,
metadataClient,
new FriendPresenceNotifier()
notifier = new FriendPresenceNotifier
{
// Speeds up tests that don't rely on this debounce a little bit.
OfflineDebounceTime = 0
}
}
};
@@ -127,5 +132,27 @@ namespace osu.Game.Tests.Visual.Components
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
}
[Test]
public void TestOfflineDebounce()
{
AddStep("set debounce time", () =>
{
notifier.NotificationDebounceTime = 0;
notifier.OfflineDebounceTime = 5000;
});
AddStep("bring friend online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
AddUntilStep("online notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
for (int i = 0; i < 3; i++)
{
AddStep("bring friend online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
AddStep("bring friend offline", () => metadataClient.FriendPresenceUpdated(1, null));
}
AddUntilStep("online notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
AddUntilStep("offline notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
}
}
}
@@ -503,27 +503,27 @@ namespace osu.Game.Tests.Visual.Gameplay
InputManager.Click(MouseButton.Left);
});
AddStep("Right-click TopLeft anchor", () =>
AddStep("Click TopLeft anchor", () =>
{
InputManager.MoveMouseTo(getMenuItemByText("TopLeft"));
InputManager.Click(MouseButton.Right);
InputManager.Click(MouseButton.Left);
});
AddAssert("TopLeft item checked", () => (getMenuItemByText("TopLeft").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True);
AddStep("Right-click Centre anchor", () =>
AddStep("Click Centre anchor", () =>
{
InputManager.MoveMouseTo(getMenuItemByText("Centre"));
InputManager.Click(MouseButton.Right);
InputManager.Click(MouseButton.Left);
});
AddAssert("Centre item checked", () => (getMenuItemByText("Centre").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True);
AddAssert("TopLeft item unchecked", () => (getMenuItemByText("TopLeft").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.False);
AddStep("Right-click Closest anchor", () =>
AddStep("Click Closest anchor", () =>
{
InputManager.MoveMouseTo(getMenuItemByText("Closest"));
InputManager.Click(MouseButton.Right);
InputManager.Click(MouseButton.Left);
});
AddAssert("Closest item checked", () => (getMenuItemByText("Closest").Item as TernaryStateRadioMenuItem)?.State.Value == TernaryState.True);
@@ -17,6 +17,7 @@ using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
@@ -57,6 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
Dependencies.CacheAs<BeatmapStore>(new RealmDetachedBeatmapStore());
Dependencies.Cache(new ChannelManager(API));
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
@@ -439,6 +441,27 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("countdown started", () => MultiplayerClient.ServerRoom!.ActiveCountdowns.Any());
}
[Test]
public void TestRoll()
{
AddStep("set playlist", () =>
{
room.Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
];
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddStep("set channel", () => room.ChannelId = 1);
AddUntilStep("wait for room join", () => RoomJoined);
AddStep("roll", () => MultiplayerClient.SendMatchRequest(new RollRequest()));
}
[Test]
public void TestSettingsRemainsOpenOnRoomUpdate()
{
@@ -56,6 +56,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestAddReferee()
{
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
AddStep("add user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(3)
{
User = new APIUser
{
Id = 3,
Username = "Second",
CoverUrl = TestResources.COVER_IMAGE_3,
},
Role = MultiplayerRoomUserRole.Referee
}));
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestAddUnresolvedUser()
{
@@ -116,6 +116,55 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("user still on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestTeamChangesLocked()
{
createRoom(() => new Room
{
Name = "Test Room",
Type = MatchType.TeamVersus,
Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
]
});
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("add another user", () => multiplayerClient.AddUser(new APIUser { Username = "otheruser", Id = 44 }));
AddStep("lock room", () =>
{
var roomState = TeamVersusRoomState.CreateDefault();
roomState.Locked = true;
multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely();
});
AddStep("press own button", () =>
{
InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("unlock room", () =>
{
var roomState = TeamVersusRoomState.CreateDefault();
roomState.Locked = false;
multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely();
});
AddStep("press own button", () =>
{
InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("user on team 1", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1);
AddStep("press own button again", () => InputManager.Click(MouseButton.Left));
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestSettingsUpdatedWhenChangingMatchType()
{
@@ -140,13 +140,14 @@ namespace osu.Game.Tests.Visual.Playlists
room = new Room
{
RoomID = 1,
MaxAttempts = 10,
Playlist =
[
// osu! beatmap
new PlaylistItem(importedSet.Beatmaps[0])
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID,
Freestyle = true
Freestyle = true,
},
// osu! beatmap converted played in taiko
new PlaylistItem(importedSet.Beatmaps[1])
@@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.Settings
private readonly Bindable<float> current = new Bindable<float>
{
Default = default,
Default = 0,
Value = 1,
};
@@ -46,7 +46,7 @@ namespace osu.Game.Tests.Visual.Settings
foreach (var revertToDefaultButton in this.ChildrenOfType<RevertToDefaultButton<float>>())
revertToDefaultButton.Parent!.Scale = new Vector2(scale);
});
AddToggleStep("toggle default state", state => current.Value = state ? default : 1);
AddToggleStep("toggle default state", state => current.Value = state ? 0 : 1);
AddToggleStep("toggle disabled state", state => current.Disabled = state);
}
}
@@ -17,6 +17,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Rulesets;
using osu.Game.Screens.Select;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Input;
@@ -27,6 +28,9 @@ namespace osu.Game.Tests.Visual.SongSelect
{
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
private DialogOverlay dialogOverlay = null!;
private RulesetStore rulesets = null!;
private BeatmapManager beatmapManager = null!;
@@ -1,283 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Tests.Resources;
using osuTK.Input;
using Realms;
namespace osu.Game.Tests.Visual.UserInterface
{
public partial class TestSceneCollectionDropdown : OsuManualInputManagerTestScene
{
private RulesetStore rulesets = null!;
private BeatmapManager beatmapManager = null!;
private CollectionDropdown dropdown = null!;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[BackgroundDependencyLoader]
private void load(GameHost host)
{
Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
}
[SetUp]
public void SetUp() => Schedule(() =>
{
writeAndRefresh(r => r.RemoveAll<BeatmapCollection>());
Child = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Child = dropdown = new CollectionDropdown
{
Width = 300,
Y = 100,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
};
});
[Test]
public void TestEmptyCollectionFilterContainsAllBeatmaps()
{
assertCollectionDropdownContains(CollectionsStrings.AllBeatmaps);
assertCollectionHeaderDisplays(CollectionsStrings.AllBeatmaps);
}
[Test]
public void TestCollectionAddedToDropdown()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
assertCollectionDropdownContains("1");
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionsCleared()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "3"))));
AddUntilStep("check count 5", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(5));
AddStep("delete all collections", () => writeAndRefresh(r => r.RemoveAll<BeatmapCollection>()));
AddUntilStep("check count 2", () => dropdown.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Menu.DrawableMenuItem>().Count(), () => Is.EqualTo(2));
}
[Test]
public void TestCollectionRemovedFromDropdown()
{
BeatmapCollection first = null!;
AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1"))));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first)));
assertCollectionDropdownContains("1", false);
assertCollectionDropdownContains("2");
}
[Test]
public void TestCollectionRenamed()
{
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select collection", () => dropdown.Current.Value = dropdown.ItemSource.ElementAt(1));
addExpandHeaderStep();
AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First"));
assertCollectionDropdownContains("First");
assertCollectionHeaderDisplays("First");
}
[Test]
public void TestAllBeatmapFilterDoesNotHaveAddButton()
{
addExpandHeaderStep();
AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0)));
AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent);
}
[Test]
public void TestCollectionFilterHasAddButton()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1)));
AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent);
}
[Test]
public void TestButtonDisabledAndEnabledWithBeatmapChanges()
{
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value);
AddStep("set dummy beatmap", () => Beatmap.SetDefault());
AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value);
}
[Test]
public void TestButtonChangesWhenAddedAndRemovedFromCollection()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear()));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestButtonAddsAndRemovesBeatmap()
{
addExpandHeaderStep();
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1"))));
assertCollectionDropdownContains("1");
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
addClickAddOrRemoveButtonStep(1);
AddUntilStep("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.MinusSquare);
addClickAddOrRemoveButtonStep(1);
AddUntilStep("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
assertFirstButtonIs(FontAwesome.Solid.PlusSquare);
}
[Test]
public void TestManageCollectionsFilterIsNotSelected()
{
bool received = false;
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List<string> { "abc" }))));
assertCollectionDropdownContains("1");
AddStep("select collection", () =>
{
InputManager.MoveMouseTo(getCollectionDropdownItemAt(1));
InputManager.Click(MouseButton.Left);
});
addExpandHeaderStep();
AddStep("watch for filter requests", () =>
{
received = false;
dropdown.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
});
AddStep("click manage collections filter", () =>
{
int lastItemIndex = dropdown.ChildrenOfType<CollectionDropdown>().Single().Items.Count() - 1;
InputManager.MoveMouseTo(getCollectionDropdownItemAt(lastItemIndex));
InputManager.Click(MouseButton.Left);
});
AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1");
AddAssert("filter request not fired", () => !received);
}
private void writeAndRefresh(Action<Realm> action) => Realm.Write(r =>
{
action(r);
r.Refresh();
});
private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All<BeatmapCollection>().First());
private void assertCollectionHeaderDisplays(LocalisableString collectionName, bool shouldDisplay = true)
=> AddUntilStep($"collection dropdown header displays '{collectionName}'",
() => shouldDisplay == dropdown.ChildrenOfType<CollectionDropdown.OsuDropdownHeader>().Any(h => h.ChildrenOfType<SpriteText>().Any(t => t.Text == collectionName)));
private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon));
private void assertCollectionDropdownContains(LocalisableString collectionName, bool shouldContain = true) =>
AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'",
// A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872
() => shouldContain == dropdown.ChildrenOfType<Menu.DrawableMenuItem>().Any(i => i.ChildrenOfType<CompositeDrawable>().OfType<IHasText>().First().Text == collectionName));
private IconButton getAddOrRemoveButton(int index)
=> getCollectionDropdownItemAt(index).ChildrenOfType<IconButton>().Single();
private void addExpandHeaderStep() => AddStep("expand header", () =>
{
InputManager.MoveMouseTo(dropdown.ChildrenOfType<CollectionDropdown.OsuDropdownHeader>().Single());
InputManager.Click(MouseButton.Left);
});
private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () =>
{
InputManager.MoveMouseTo(getAddOrRemoveButton(index));
InputManager.Click(MouseButton.Left);
});
private Menu.DrawableMenuItem getCollectionDropdownItemAt(int index)
{
// todo: we should be able to use Items, but apparently that's not guaranteed to be ordered... see: https://github.com/ppy/osu-framework/pull/6079
CollectionFilterMenuItem item = dropdown.ChildrenOfType<CollectionDropdown>().Single().ItemSource.ElementAt(index);
return dropdown.ChildrenOfType<Menu.DrawableMenuItem>().Single(i => i.Item.Text.Value == item.CollectionName);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
}
}
@@ -114,51 +114,6 @@ namespace osu.Game.Tests.Visual.UserInterface
=> AddAssert($"state is {expected}", () => state.Value == expected);
}
[Test]
public void TestItemRespondsToRightClick()
{
OsuMenu menu = null;
Bindable<TernaryState> state = new Bindable<TernaryState>(TernaryState.Indeterminate);
AddStep("create menu", () =>
{
state.Value = TernaryState.Indeterminate;
Child = menu = new OsuMenu(Direction.Vertical, true)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Items = new[]
{
new TernaryStateToggleMenuItem("First"),
new TernaryStateToggleMenuItem("Second") { State = { BindTarget = state } },
new TernaryStateToggleMenuItem("Third") { State = { Value = TernaryState.True } },
}
};
});
checkState(TernaryState.Indeterminate);
click();
checkState(TernaryState.True);
click();
checkState(TernaryState.False);
AddStep("change state via bindable", () => state.Value = TernaryState.True);
void click() =>
AddStep("click", () =>
{
InputManager.MoveMouseTo(menu.ScreenSpaceDrawQuad.Centre);
InputManager.Click(MouseButton.Right);
});
void checkState(TernaryState expected)
=> AddAssert($"state is {expected}", () => state.Value == expected);
}
[Test]
public void TestCustomState()
{
@@ -10,5 +10,9 @@
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="ppy.LocalisationAnalyser" Version="2025.1208.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+1 -1
View File
@@ -424,7 +424,7 @@ namespace osu.Game.Beatmaps
Stream IWorkingBeatmap.GetStream(string storagePath) => working.GetStream(storagePath);
void IWorkingBeatmap.BeginAsyncLoad() => working.BeginAsyncLoad();
void IWorkingBeatmap.CancelAsyncLoad() => working.CancelAsyncLoad();
void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint);
void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double? offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint);
}
}
}
+1 -1
View File
@@ -296,7 +296,7 @@ namespace osu.Game.Beatmaps
return Realm.Run(r =>
{
r.Refresh();
return r.All<BeatmapSetInfo>().Where(b => !b.DeletePending).Detach();
return r.All<BeatmapSetInfo>().Where(b => !b.DeletePending).AsEnumerable().Detach();
});
}
+2 -1
View File
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Logging;
@@ -36,7 +37,7 @@ namespace osu.Game.Beatmaps
public void Queue(Live<BeatmapSetInfo> beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst)
{
Logger.Log($"Queueing change for local beatmap {beatmapSet}");
Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, lookupScope)), default, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously,
Task.Factory.StartNew(() => beatmapSet.PerformRead(b => Process(b, lookupScope)), CancellationToken.None, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously,
updateScheduler);
}
+1 -1
View File
@@ -139,6 +139,6 @@ namespace osu.Game.Beatmaps
/// <summary>
/// Reads the correct track restart point from beatmap metadata and sets looping to enabled.
/// </summary>
void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0);
void PrepareTrackForPreview(bool looping, double? offsetFromPreviewPoint = null);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ using osu.Game.Rulesets.Difficulty;
namespace osu.Game.Beatmaps
{
public readonly struct StarDifficulty
public readonly record struct StarDifficulty
{
/// <summary>
/// The star difficulty rating for the given beatmap.
+6 -3
View File
@@ -16,6 +16,7 @@ using osu.Framework.Audio.Track;
using osu.Framework.Extensions;
using osu.Framework.Graphics.Textures;
using osu.Framework.Logging;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
@@ -119,7 +120,7 @@ namespace osu.Game.Beatmaps
return track;
}
public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0)
public void PrepareTrackForPreview(bool looping, double? offsetFromPreviewPoint = null)
{
Track.Looping = looping;
Track.RestartPoint = Metadata.PreviewTime;
@@ -133,7 +134,9 @@ namespace osu.Game.Beatmaps
if (Track.RestartPoint < 0 || Track.RestartPoint > Track.Length)
Track.RestartPoint = 0.4f * Track.Length;
Track.RestartPoint = Math.Clamp(Track.RestartPoint + offsetFromPreviewPoint, 0, Track.Length);
offsetFromPreviewPoint ??= -MusicController.TRACK_FADE_IN_TIME;
Track.RestartPoint = Math.Clamp(Track.RestartPoint + offsetFromPreviewPoint.Value, 0, Track.Length);
}
/// <summary>
@@ -262,7 +265,7 @@ namespace osu.Game.Beatmaps
using (var cancellationTokenSource = new CancellationTokenSource(10_000))
{
// don't apply the default timeout when debugger is attached (may be breakpointing / debugging).
return GetPlayableBeatmap(ruleset, mods ?? Array.Empty<Mod>(), Debugger.IsAttached ? new CancellationToken() : cancellationTokenSource.Token);
return GetPlayableBeatmap(ruleset, mods ?? Array.Empty<Mod>(), Debugger.IsAttached ? CancellationToken.None : cancellationTokenSource.Token);
}
}
catch (OperationCanceledException)
-283
View File
@@ -1,283 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
using Realms;
namespace osu.Game.Collections
{
/// <summary>
/// A dropdown to select the collection to be used to filter results.
/// WARNING: TODO: we have TWO `CollectionDropdowns` with diverging functionality. This is not good.
/// </summary>
public partial class CollectionDropdown : OsuDropdown<CollectionFilterMenuItem>
{
/// <summary>
/// Whether to show the "manage collections..." menu item in the dropdown.
/// </summary>
protected virtual bool ShowManageCollectionsItem => true;
public Action? RequestFilter { private get; set; }
private readonly BindableList<CollectionFilterMenuItem> filters = new BindableList<CollectionFilterMenuItem>();
[Resolved]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
[Resolved]
private RealmAccess realm { get; set; } = null!;
private IDisposable? realmSubscription;
private readonly CollectionFilterMenuItem allBeatmapsItem = new AllBeatmapsCollectionFilterMenuItem();
public CollectionDropdown()
{
ItemSource = filters;
Current.Value = allBeatmapsItem;
AlwaysShowSearchBar = true;
}
protected override void LoadComplete()
{
base.LoadComplete();
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>().OrderBy(c => c.Name), collectionsChanged);
Current.BindValueChanged(selectionChanged);
}
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes)
{
if (changes == null)
{
filters.Clear();
filters.Add(allBeatmapsItem);
filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm))));
if (ShowManageCollectionsItem)
filters.Add(new ManageCollectionsFilterMenuItem());
}
else
{
foreach (int i in changes.DeletedIndices.OrderDescending())
filters.RemoveAt(i + 1);
foreach (int i in changes.InsertedIndices)
filters.Insert(i + 1, new CollectionFilterMenuItem(collections[i].ToLive(realm)));
var selectedItem = SelectedItem?.Value;
foreach (int i in changes.NewModifiedIndices)
{
var updatedItem = collections[i];
// This is responsible for updating the state of the +/- button and the collection's name.
// TODO: we can probably make the menu items update with changes to avoid this.
filters.RemoveAt(i + 1);
filters.Insert(i + 1, new CollectionFilterMenuItem(updatedItem.ToLive(realm)));
if (updatedItem.ID == selectedItem?.Collection?.ID)
{
// This current update and schedule is required to work around dropdown headers not updating text even when the selected item
// changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue
// a warning that it's going to be a frustrating journey.
Current.Value = allBeatmapsItem;
Schedule(() =>
{
// current may have changed before the scheduled call is run.
if (Current.Value != allBeatmapsItem)
return;
Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0];
});
// Trigger an external re-filter if the current item was in the change set.
RequestFilter?.Invoke();
break;
}
}
}
}
private Live<BeatmapCollection>? lastFiltered;
private void selectionChanged(ValueChangedEvent<CollectionFilterMenuItem> filter)
{
// May be null during .Clear().
if (filter.NewValue.IsNull())
return;
// Never select the manage collection filter - rollback to the previous filter.
// This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value.
if (filter.NewValue is ManageCollectionsFilterMenuItem)
{
Current.Value = filter.OldValue;
manageCollectionsDialog?.Show();
return;
}
var newCollection = filter.NewValue.Collection;
// This dropdown be weird.
// We only care about filtering if the actual collection has changed.
if (newCollection != lastFiltered)
{
RequestFilter?.Invoke();
lastFiltered = newCollection;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
realmSubscription?.Dispose();
}
protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName;
protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader();
protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu();
protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader();
protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu();
public partial class CollectionDropdownHeader : OsuDropdownHeader
{
public CollectionDropdownHeader()
{
Height = 25;
Chevron.Size = new Vector2(12);
Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 8 };
}
}
protected partial class CollectionDropdownMenu : OsuDropdownMenu
{
public CollectionDropdownMenu()
{
MaxHeight = 200;
}
protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item)
{
BackgroundColourHover = HoverColour,
BackgroundColourSelected = SelectionColour
};
}
protected partial class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem
{
private IconButton addOrRemoveButton = null!;
private bool beatmapInCollection;
private readonly Live<BeatmapCollection>? collection;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
public CollectionDropdownDrawableMenuItem(MenuItem item)
: base(item)
{
collection = ((DropdownMenuItem<CollectionFilterMenuItem>)item).Value.Collection;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(addOrRemoveButton = new NoFocusChangeIconButton
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
X = -OsuScrollContainer.SCROLL_BAR_WIDTH,
Scale = new Vector2(0.65f),
Action = addOrRemove,
});
}
protected override void LoadComplete()
{
base.LoadComplete();
if (collection != null)
{
beatmap.BindValueChanged(_ =>
{
beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash));
addOrRemoveButton.Enabled.Value = !beatmap.IsDefault;
addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare;
addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap";
updateButtonVisibility();
}, true);
}
updateButtonVisibility();
}
protected override bool OnHover(HoverEvent e)
{
updateButtonVisibility();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateButtonVisibility();
base.OnHoverLost(e);
}
protected override void OnSelectChange()
{
base.OnSelectChange();
updateButtonVisibility();
}
private void updateButtonVisibility()
{
if (collection == null)
addOrRemoveButton.Alpha = 0;
else
addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0;
}
private void addOrRemove()
{
Debug.Assert(collection != null);
Task.Run(() => collection.PerformWrite(c =>
{
if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash))
c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash);
}));
}
protected override Drawable CreateContent() => (Content)base.CreateContent();
private partial class NoFocusChangeIconButton : IconButton
{
public override bool ChangeFocusOnClick => false;
}
}
}
}
@@ -71,8 +71,15 @@ namespace osu.Game.Database
// Encode to legacy format
var stream = new MemoryStream();
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
// Maintain line endings in windows style.
// If we don't do that, uploads to BSS may show changes where there are none.
sw.NewLine = "\r\n";
new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw);
}
stream.Seek(0, SeekOrigin.Begin);
@@ -81,6 +88,16 @@ namespace osu.Game.Database
protected virtual void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap)
{
// Limit grid sizes to those which stable knows about.
if (playableBeatmap.GridSize >= 24)
playableBeatmap.GridSize = 32;
else if (playableBeatmap.GridSize >= 12)
playableBeatmap.GridSize = 16;
else if (playableBeatmap.GridSize >= 6)
playableBeatmap.GridSize = 8;
else
playableBeatmap.GridSize = 4;
// Convert beatmap elements to be compatible with legacy format
// So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves
+1 -1
View File
@@ -1227,7 +1227,7 @@ namespace osu.Game.Database
var oldKeyBindingsQuery = migration.NewRealm
.All<RealmKeyBinding>()
.Where(kb => kb.RulesetName == @"mania" && kb.Variant == variant);
var oldKeyBindings = oldKeyBindingsQuery.Detach();
var oldKeyBindings = oldKeyBindingsQuery.AsEnumerable().Detach();
migration.NewRealm.RemoveRange(oldKeyBindingsQuery);
+2 -2
View File
@@ -55,9 +55,9 @@ namespace osu.Game.Extensions
{
tcs.TrySetResult(true);
}
}, cancellationToken: default);
}, cancellationToken: CancellationToken.None);
}
}, cancellationToken: default);
}, cancellationToken: CancellationToken.None);
// importantly, we are not returning the continuation itself but rather a task which represents its status in sequential execution order.
// this will not be cancelled or completed until the previous task has also.
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
@@ -21,10 +22,13 @@ namespace osu.Game.Graphics.Backgrounds
{
private readonly InterpolatingFramedClock storyboardClock;
private AudioContainer storyboardContainer = null!;
public readonly AudioContainer Storyboard;
private DrawableStoryboard? drawableStoryboard;
private CancellationTokenSource? loadCancellationSource = new CancellationTokenSource();
public Action? StoryboardLoaded { get; set; }
[Resolved(CanBeNull = true)]
private MusicController? musicController { get; set; }
@@ -35,17 +39,17 @@ namespace osu.Game.Graphics.Backgrounds
: base(beatmap, fallbackTextureName)
{
storyboardClock = new InterpolatingFramedClock();
AddInternal(Storyboard = new AudioContainer
{
RelativeSizeAxes = Axes.Both,
Volume = { Value = 0 },
});
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(storyboardContainer = new AudioContainer
{
RelativeSizeAxes = Axes.Both,
Volume = { Value = 0 },
});
LoadStoryboard(false);
}
@@ -74,8 +78,10 @@ namespace osu.Game.Graphics.Backgrounds
if (Beatmap.Storyboard.ReplacesBackground)
Sprite.FadeOut(BackgroundScreen.TRANSITION_LENGTH, Easing.InQuint);
storyboardContainer.FadeInFromZero(BackgroundScreen.TRANSITION_LENGTH, Easing.OutQuint);
storyboardContainer.Add(s);
Storyboard.FadeInFromZero(BackgroundScreen.TRANSITION_LENGTH, Easing.OutQuint);
Storyboard.Add(s);
StoryboardLoaded?.Invoke();
}
}
@@ -88,7 +94,7 @@ namespace osu.Game.Graphics.Backgrounds
loadCancellationSource = null;
// clear is intentionally used here for the storyboard to be disposed asynchronously.
storyboardContainer.Clear();
Storyboard.Clear();
drawableStoryboard = null;
@@ -35,7 +35,7 @@ namespace osu.Game.Graphics.Containers
{
}
protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default)
protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null)
{
UserScrolling = true;
base.OnUserScroll(value, animated, distanceDecay);
+5 -5
View File
@@ -688,11 +688,11 @@ namespace osu.Game.Graphics
public class Glyph : ITexturedCharacterGlyph
{
public float XOffset => default;
public float YOffset => default;
public float XAdvance => default;
public float Baseline => default;
public char Character => default;
public float XOffset => 0;
public float YOffset => 0;
public float XAdvance => 0;
public float Baseline => 0;
public char Character => '\0';
public float GetKerning<T>(T lastGlyph) where T : ICharacterGlyph => throw new NotImplementedException();
@@ -4,9 +4,7 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osuTK;
using osuTK.Input;
namespace osu.Game.Graphics.UserInterface
{
@@ -14,6 +12,8 @@ namespace osu.Game.Graphics.UserInterface
{
protected new StatefulMenuItem Item => (StatefulMenuItem)base.Item;
public override bool CloseMenuOnClick => false;
public DrawableStatefulMenuItem(StatefulMenuItem item)
: base(item)
{
@@ -21,19 +21,6 @@ namespace osu.Game.Graphics.UserInterface
protected override TextContainer CreateTextContainer() => new ToggleTextContainer(Item);
protected override bool OnMouseDown(MouseDownEvent e)
{
// Right mouse button is a special case where we allow actioning without dismissing the menu.
// This is achieved by not calling `Clicked` (as done by the base implementation in OnClick).
if (IsActionable && e.Button == MouseButton.Right)
{
Item.Action.Value?.Invoke();
return true;
}
return false;
}
private partial class ToggleTextContainer : TextContainer
{
private readonly StatefulMenuItem menuItem;
+21
View File
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Input;
@@ -55,5 +57,24 @@ namespace osu.Game.Graphics.UserInterface
return result;
}
public bool Equals(Hotkey other)
{
if (KeyCombinations == null && other.KeyCombinations != null)
return false;
if (KeyCombinations != null && other.KeyCombinations == null)
return false;
bool result = (KeyCombinations == null && other.KeyCombinations == null) || KeyCombinations!.SequenceEqual(other.KeyCombinations!);
result &= GlobalAction == other.GlobalAction;
result &= PlatformAction == other.PlatformAction;
return result;
}
public override int GetHashCode()
{
return HashCode.Combine(StructuralComparisons.StructuralEqualityComparer.GetHashCode(KeyCombinations ?? []), GlobalAction, PlatformAction);
}
}
}
@@ -2,6 +2,7 @@
// 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.Containers;
using osu.Framework.Graphics.Shapes;
@@ -14,8 +15,20 @@ namespace osu.Game.Graphics.UserInterface
{
public partial class SectionHeader : CompositeDrawable
{
/// <summary>
/// Extra text to be shown in brackets next to the header.
/// Unlike the header itself, this can be updated during runtime.
/// </summary>
public readonly Bindable<string> DetailsText = new Bindable<string>();
private readonly LocalisableString text;
private OsuTextFlowContainer textFlow = null!;
private ITextPart? detailsPart;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public SectionHeader(LocalisableString text)
{
this.text = text;
@@ -27,7 +40,7 @@ namespace osu.Game.Graphics.UserInterface
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
private void load()
{
InternalChild = new FillFlowContainer
{
@@ -37,7 +50,7 @@ namespace osu.Game.Graphics.UserInterface
Spacing = new Vector2(2),
Children = new Drawable[]
{
new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold))
textFlow = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold))
{
Text = text,
RelativeSizeAxes = Axes.X,
@@ -51,5 +64,21 @@ namespace osu.Game.Graphics.UserInterface
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
DetailsText.BindValueChanged(updateDetails, true);
}
private void updateDetails(ValueChangedEvent<string> details)
{
if (detailsPart != null)
textFlow.RemovePart(detailsPart);
if (!string.IsNullOrEmpty(details.NewValue))
detailsPart = textFlow.AddText($" ({details.NewValue})", t => t.Colour = colourProvider.Highlight1);
}
}
}
@@ -74,7 +74,7 @@ namespace osu.Game.Input.Bindings
{
var defaults = DefaultKeyBindings.ToList();
List<RealmKeyBinding> newBindings = realmKeyBindings.Detach()
List<RealmKeyBinding> newBindings = realmKeyBindings.AsEnumerable().Detach()
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
@@ -235,6 +235,11 @@ Click to see what's new!", version);
/// </summary>
public static LocalisableString ElevatedPrivileges(LocalisableString user) => new TranslatableString(getKey(@"elevated_privileges"), @"Running osu! as {0} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user.", user);
/// <summary>
/// "On macOS, installing osu! to a directory other than /Applications or {0}/Applications can cause issues with updating the game. Please move your game installation to one of these locations."
/// </summary>
public static LocalisableString MacOSAppLocation(LocalisableString userProfile) => new TranslatableString(getKey(@"macos_app_location"), @"On macOS, installing osu! to a directory other than /Applications or {0}/Applications can cause issues with updating the game. Please move your game installation to one of these locations.", userProfile);
/// <summary>
/// "Screenshot saved! Click to view.
/// {0}"
@@ -49,6 +49,31 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString PlaylistTrayDescription => new TranslatableString(getKey(@"playlist_tray_description"), @"Manage items on previous screen");
/// <summary>
/// "Beatmap queue"
/// </summary>
public static LocalisableString MultiplayerBeatmapQueue => new TranslatableString(getKey(@"multiplayer_beatmap_queue"), @"Beatmap queue");
/// <summary>
/// "Progress"
/// </summary>
public static LocalisableString PlaylistProgress => new TranslatableString(getKey(@"playlist_progress"), @"Progress");
/// <summary>
/// "Leaderboard"
/// </summary>
public static LocalisableString PlaylistLeaderboard => new TranslatableString(getKey(@"playlist_leaderboard"), @"Leaderboard");
/// <summary>
/// "Difficulty"
/// </summary>
public static LocalisableString Difficulty => new TranslatableString(getKey(@"difficulty"), @"Difficulty");
/// <summary>
/// "Chat"
/// </summary>
public static LocalisableString Chat => new TranslatableString(getKey(@"chat"), @"Chat");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -43,6 +43,11 @@ Leaderboards may be reset.");
public static LocalisableString QualifiedBeatmapDisclaimerContent => new TranslatableString(getKey(@"qualified_beatmap_disclaimer_content"), @"No performance points will be awarded.
Leaderboards will be reset when the beatmap is ranked.");
/// <summary>
/// "Loading paused..."
/// </summary>
public static LocalisableString LoadingPaused => new TranslatableString(getKey(@"loading_paused"), @"Loading paused...");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}
@@ -145,7 +145,7 @@ namespace osu.Game.Online.API.Requests.Responses
// Generally this is required because this model may be used by server-side components, but
// we don't want to bother sending these fields in score submission requests, for instance.
public bool ShouldSerializeEndedAt() => EndedAt != default;
public bool ShouldSerializeStartedAt() => StartedAt != default;
public bool ShouldSerializeStartedAt() => StartedAt != null;
public bool ShouldSerializeLegacyScoreId() => LegacyScoreId != null;
public bool ShouldSerializeLegacyTotalScore() => LegacyTotalScore != null;
public bool ShouldSerializeMods() => Mods.Length > 0;
+115 -8
View File
@@ -6,19 +6,30 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Threading;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Overlays;
using osu.Game.Overlays.Chat.Listing;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Online.Chat
{
@@ -70,6 +81,18 @@ namespace osu.Game.Online.Chat
[Resolved]
private UserLookupCache users { get; set; }
[Resolved(CanBeNull = true)]
[CanBeNull]
private MultiplayerClient multiplayerClient { get; set; }
[Resolved(CanBeNull = true)]
[CanBeNull]
private Storage storage { get; set; }
[Resolved(CanBeNull = true)]
[CanBeNull]
private INotificationOverlay notifications { get; set; }
private readonly IBindable<APIUser> localUser = new Bindable<APIUser>();
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
private readonly IBindableList<APIRelation> localUserBlocks = new BindableList<APIRelation>();
@@ -264,11 +287,11 @@ namespace osu.Game.Online.Chat
switch (command.ToLowerInvariant())
{
case "np":
case @"np":
AddInternal(new NowPlayingCommand(target));
break;
case "me":
case @"me":
if (string.IsNullOrWhiteSpace(content))
{
target.AddNewMessages(new ErrorMessage("Usage: /me [action]"));
@@ -278,7 +301,7 @@ namespace osu.Game.Online.Chat
PostMessage(content, true, target);
break;
case "join":
case @"join":
if (string.IsNullOrWhiteSpace(content))
{
target.AddNewMessages(new ErrorMessage("Usage: /join [channel]"));
@@ -296,9 +319,9 @@ namespace osu.Game.Online.Chat
JoinChannel(channel);
break;
case "chat":
case "msg":
case "query":
case @"chat":
case @"msg":
case @"query":
if (string.IsNullOrWhiteSpace(content))
{
target.AddNewMessages(new ErrorMessage($"Usage: /{command} [user]"));
@@ -323,8 +346,72 @@ namespace osu.Game.Online.Chat
api.Queue(request);
break;
case "help":
target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np"));
case @"roll":
if (target.Type != ChannelType.Multiplayer || multiplayerClient?.Room?.ChannelID != target.Id)
{
target.AddNewMessages(new ErrorMessage("Cannot roll when not in a multiplayer room."));
break;
}
uint max = 100;
if (!string.IsNullOrEmpty(content))
{
if (!uint.TryParse(content, out max) || max < 2 || max > 100)
{
target.AddNewMessages(new ErrorMessage("Usage: /roll [2-100]"));
break;
}
}
var rollRequest = new RollRequest { Max = max };
multiplayerClient.SendMatchRequest(rollRequest).FireAndForget(onError: ex =>
{
string message = ex is HubException
? $"Failed to roll: {ex.Message}"
: "Failed to roll.";
target.AddNewMessages(new ErrorMessage(message));
});
break;
case @"savelog":
ProgressNotification notification = new ProgressNotification
{
State = ProgressNotificationState.Active,
Text = NotificationsStrings.LogsExportOngoing,
};
notifications?.Post(notification);
exportChannelLog(target).ContinueWith(t =>
{
if (t.Exception != null)
{
Logger.Log($@"Failed to export channel log: {t.Exception}");
notification.State = ProgressNotificationState.Cancelled;
return;
}
string result = t.GetResultSafely();
if (result == null)
{
Logger.Log("Failed to export channel log due to missing storage.");
notification.State = ProgressNotificationState.Cancelled;
return;
}
notification.CompletionText = NotificationsStrings.FileExportFinished(result);
notification.CompletionClickAction = () =>
{
(storage as OsuStorage)?.GetExportStorage().PresentFileExternally(result);
return true;
};
notification.State = ProgressNotificationState.Completed;
});
break;
case @"help":
target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np, /savelog, /roll [2-100] (multiplayer only)"));
break;
default:
@@ -679,6 +766,26 @@ namespace osu.Game.Online.Chat
}
}
[ItemCanBeNull]
private async Task<string> exportChannelLog(Channel channel)
{
if (storage is not OsuStorage osuStorage)
return null;
string filename = string.Format($@"chat-{channel.Name}-{DateTimeOffset.Now:yyyyMMdd-hhmmss}.txt").GetValidFilename();
var exportStorage = osuStorage.GetExportStorage();
using (var file = exportStorage.CreateFileSafely(filename))
{
using var textWriter = new StreamWriter(file);
foreach (var message in channel.Messages)
await textWriter.WriteLineAsync($@"{message.Timestamp:yyyy-MM-dd HH:mm} {message.Sender.Username}: {message.Content}").ConfigureAwait(false);
}
return filename;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
+61 -6
View File
@@ -25,6 +25,16 @@ namespace osu.Game.Online
{
public partial class FriendPresenceNotifier : Component
{
/// <summary>
/// Minimum time between subsequent online/offline notifications.
/// </summary>
public double NotificationDebounceTime { get; set; } = 1000;
/// <summary>
/// Minimum time after a user has gone offline, before they're added to the offline alert queue.
/// </summary>
public double OfflineDebounceTime { get; set; } = 15000;
[Resolved]
private INotificationOverlay notifications { get; set; } = null!;
@@ -42,13 +52,31 @@ namespace osu.Game.Online
private readonly IBindableList<APIRelation> friends = new BindableList<APIRelation>();
private readonly IBindableDictionary<int, UserPresence> friendPresences = new BindableDictionary<int, UserPresence>();
/// <summary>
/// List of users that will be notified as having come online with the next notification.
/// </summary>
private readonly HashSet<APIUser> onlineAlertQueue = new HashSet<APIUser>();
/// <summary>
/// List of users that will be notified as having gone offline with the next notification.
/// </summary>
private readonly HashSet<APIUser> offlineAlertQueue = new HashSet<APIUser>();
private double? nextOnlineAlertTime;
private double? nextOfflineAlertTime;
/// <summary>
/// List of users that have gone offline, but we're waiting for them to potentially come online again before queueing them for notification.
/// For example, if a user is quickly toggling between the "Online" and "Appear Offline" states.
/// </summary>
private readonly HashSet<APIUser> pendingOfflineUsers = new HashSet<APIUser>();
private const double debounce_time_before_notification = 1000;
/// <summary>
/// The post time for the next online notification.
/// </summary>
private double? nextOnlineAlertTime;
/// <summary>
/// The post time for the next offline notification.
/// </summary>
private double? nextOfflineAlertTime;
protected override void LoadComplete()
{
@@ -133,28 +161,55 @@ namespace osu.Game.Online
APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId);
if (friend?.TargetUser is APIUser user)
markUserOffline(user);
markUserOfflineDebounced(user);
}
break;
}
}
/// <summary>
/// Immediately registers a user for the next online notification alert.
/// </summary>
private void markUserOnline(APIUser user)
{
if (pendingOfflineUsers.Remove(user))
return;
if (!offlineAlertQueue.Remove(user))
{
onlineAlertQueue.Add(user);
nextOnlineAlertTime ??= Time.Current + debounce_time_before_notification;
nextOnlineAlertTime ??= Time.Current + NotificationDebounceTime;
}
}
/// <summary>
/// Waits <see cref="OfflineDebounceTime"/> before adding a user to the next offline notification alert.
/// </summary>
/// <param name="user"></param>
private void markUserOfflineDebounced(APIUser user)
{
pendingOfflineUsers.Add(user);
Scheduler.AddDelayed(() =>
{
// Check if the friend has come back online.
if (!pendingOfflineUsers.Remove(user))
return;
markUserOffline(user);
}, OfflineDebounceTime);
}
/// <summary>
/// Immediately registers a user for the next offline notification alert.
/// </summary>
private void markUserOffline(APIUser user)
{
if (!onlineAlertQueue.Remove(user))
{
offlineAlertQueue.Add(user);
nextOfflineAlertTime ??= Time.Current + debounce_time_before_notification;
nextOfflineAlertTime ??= Time.Current + NotificationDebounceTime;
}
}
@@ -175,7 +175,7 @@ namespace osu.Game.Online.Metadata
public override Task<BeatmapUpdates> GetChangesSince(int queueId)
{
if (connector?.IsConnected.Value != true)
return Task.FromCanceled<BeatmapUpdates>(default);
return Task.FromCanceled<BeatmapUpdates>(CancellationToken.None);
Logger.Log($"Requesting any changes since last known queue id {queueId}");
@@ -16,7 +16,7 @@ namespace osu.Game.Online.Notifications.WebSocket
public Func<SocketMessage, bool>? HandleMessage;
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = null)
{
if (HandleMessage?.Invoke(message) != true)
throw new InvalidOperationException($@"{nameof(DummyNotificationsClient)} cannot process this message.");
@@ -26,6 +26,6 @@ namespace osu.Game.Online.Notifications.WebSocket
/// <summary>
/// Sends a <see cref="SocketMessage"/> to the notification server.
/// </summary>
Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default);
Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = null);
}
}
@@ -97,7 +97,7 @@ namespace osu.Game.Online.Notifications.WebSocket
}
}
public async Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
public async Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = null)
{
if (socket.State != WebSocketState.Open)
return;
@@ -46,7 +46,7 @@ namespace osu.Game.Online.Notifications.WebSocket
return client;
}
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = null)
{
if (CurrentConnection is not WebSocketNotificationsClient webSocketClient)
return Task.CompletedTask;
@@ -173,7 +173,7 @@ namespace osu.Game.Online
// make sure a disconnect wasn't triggered (and this is still the active connection).
if (!hasBeenCancelled)
await Task.Run(connect, default).ConfigureAwait(false);
await Task.Run(connect, CancellationToken.None).ConfigureAwait(false);
}
protected Task Disconnect() => disconnect(true);
+1 -26
View File
@@ -36,7 +36,6 @@ using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
@@ -193,7 +192,7 @@ namespace osu.Game
protected readonly Bindable<LocalUserPlayingState> UserPlayingState = new Bindable<LocalUserPlayingState>();
protected OsuScreenStack ScreenStack;
public OsuScreenStack ScreenStack { get; private set; }
protected BackButton BackButton => screenStackFooter.BackButton;
protected ScreenFooter ScreenFooter => screenStackFooter.Footer;
@@ -1053,30 +1052,6 @@ namespace osu.Game
{
base.LoadComplete();
var languages = Enum.GetValues<Language>();
var mappings = languages.Select(language =>
{
#if DEBUG
if (language == Language.debug)
return new LocaleMapping("debug", new DebugLocalisationStore());
#endif
string cultureCode = language.ToCultureCode();
try
{
return new LocaleMapping(new ResourceManagerLocalisationStore(cultureCode));
}
catch (Exception ex)
{
Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\"");
return null;
}
}).Where(m => m != null);
Localisation.AddLocaleMappings(mappings);
// The next time this is updated is in UpdateAfterChildren, which occurs too late and results
// in the cursor being shown for a few frames during the intro.
// This prevents the cursor from showing until we have a screen with CursorVisible = true
+49
View File
@@ -10,6 +10,7 @@ using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
@@ -305,6 +306,7 @@ namespace osu.Game
MessageFormatter.WebsiteRootUrl = endpoints.WebsiteUrl;
// Initialise localisation
frameworkLocale = frameworkConfig.GetBindable<string>(FrameworkSetting.Locale);
frameworkLocale.BindValueChanged(_ => updateLanguage());
@@ -500,6 +502,33 @@ namespace osu.Game
Fonts.AddStore(new OsuIcon.OsuIconStore(Textures));
}
protected override void LoadComplete()
{
base.LoadComplete();
var localeMappings = Enum.GetValues<Language>().Select(language =>
{
#if DEBUG
if (language == Language.debug)
return new LocaleMapping("debug", new DebugLocalisationStore());
#endif
string cultureCode = language.ToCultureCode();
try
{
return new LocaleMapping(new ResourceManagerLocalisationStore(cultureCode));
}
catch (Exception ex)
{
Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\"");
return null;
}
}).Where(m => m != null);
Localisation.AddLocaleMappings(localeMappings);
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>
dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
@@ -517,6 +546,8 @@ namespace osu.Game
host.ExceptionThrown += onExceptionThrown;
}
#region Exit handling
/// <summary>
/// Use to programatically exit the game as if the user was triggering via alt-f4.
/// By default, will keep persisting until an exit occurs (exit may be blocked multiple times).
@@ -530,12 +561,28 @@ namespace osu.Game
Scheduler.AddDelayed(AttemptExit, 2000);
}
/// <summary>
/// An action that restarts the application after it has exited.
/// </summary>
[CanBeNull]
public Action RestartOnExitAction { private get; set; }
/// <summary>
/// Signals that the application should not be restarted after it is exited.
/// </summary>
public void CancelRestartOnExit()
{
RestartOnExitAction = null;
}
/// <summary>
/// If supported by the platform, the game will automatically restart after the next exit.
/// </summary>
/// <returns>Whether a restart operation was queued.</returns>
public virtual bool RestartAppWhenExited() => false;
#endregion
/// <summary>
/// Perform migration of user data to a specified path.
/// </summary>
@@ -742,6 +789,8 @@ namespace osu.Game
if (Host != null)
Host.ExceptionThrown -= onExceptionThrown;
RestartOnExitAction?.Invoke();
}
ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.BeatmapLoaded ? Beatmap.Value.Beatmap.ControlPointInfo : null;
@@ -1,13 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Metadata;
using osu.Game.Overlays.Dashboard.Friends;
using osu.Game.Resources.Localisation.Web;
@@ -15,6 +18,17 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline
{
public partial class CurrentlyOnlineDisplay : CompositeDrawable
{
/// <summary>
/// The current state of the <see cref="DashboardOverlay"/>.
/// Presence is only updated when this value is <see cref="Visibility.Visible"/>.
/// </summary>
public readonly Bindable<Visibility> OverlayState = new Bindable<Visibility>(Visibility.Visible);
[Resolved]
private MetadataClient metadataClient { get; set; } = null!;
private readonly IBindable<bool> isConnected = new Bindable<bool>();
private Box background = null!;
private UserListToolbar userListToolbar = null!;
private Container<RealtimeUserList> listContainer = null!;
@@ -22,6 +36,7 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline
private BasicSearchTextBox searchTextBox = null!;
private CancellationTokenSource? listLoadCancellation;
private IDisposable? userPresenceWatchToken;
public CurrentlyOnlineDisplay()
{
@@ -114,6 +129,11 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline
{
base.LoadComplete();
isConnected.BindTo(metadataClient.IsConnected);
isConnected.BindValueChanged(_ => updateUserPresenceState());
OverlayState.BindValueChanged(_ => updateUserPresenceState(), true);
userListToolbar.DisplayStyle.BindValueChanged(_ => reloadList(), true);
}
@@ -147,12 +167,28 @@ namespace osu.Game.Overlays.Dashboard.CurrentlyOnline
}
}
private void updateUserPresenceState()
{
if (!isConnected.Value)
return;
if (OverlayState.Value == Visibility.Visible)
userPresenceWatchToken ??= metadataClient.BeginWatchingUserPresence();
else
{
userPresenceWatchToken?.Dispose();
userPresenceWatchToken = null;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
listLoadCancellation?.Cancel();
listLoadCancellation?.Dispose();
userPresenceWatchToken?.Dispose();
}
}
}
+4 -35
View File
@@ -2,10 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.Metadata;
using osu.Game.Overlays.Dashboard;
using osu.Game.Overlays.Dashboard.CurrentlyOnline;
using osu.Game.Overlays.Dashboard.Friends;
@@ -14,12 +10,6 @@ namespace osu.Game.Overlays
{
public partial class DashboardOverlay : TabbableOnlineOverlay<DashboardOverlayHeader, DashboardOverlayTabs>
{
[Resolved]
private MetadataClient metadataClient { get; set; } = null!;
private IBindable<bool> metadataConnected = null!;
private IDisposable? userPresenceWatchToken;
public DashboardOverlay()
: base(OverlayColourScheme.Purple)
{
@@ -38,36 +28,15 @@ namespace osu.Game.Overlays
break;
case DashboardOverlayTabs.CurrentlyPlaying:
LoadDisplay(new CurrentlyOnlineDisplay());
LoadDisplay(new CurrentlyOnlineDisplay
{
OverlayState = { BindTarget = State }
});
break;
default:
throw new NotImplementedException($"Display for {tab} tab is not implemented");
}
}
protected override void LoadComplete()
{
base.LoadComplete();
metadataConnected = metadataClient.IsConnected.GetBoundCopy();
metadataConnected.BindValueChanged(_ => updateUserPresenceState());
State.BindValueChanged(_ => updateUserPresenceState());
updateUserPresenceState();
}
private void updateUserPresenceState()
{
if (!metadataConnected.Value)
return;
if (State.Value == Visibility.Visible)
userPresenceWatchToken ??= metadataClient.BeginWatchingUserPresence();
else
{
userPresenceWatchToken?.Dispose();
userPresenceWatchToken = null;
}
}
}
}
@@ -1,69 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osuTK;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Effects;
using osu.Game.Collections;
using osu.Game.Graphics;
namespace osu.Game.Overlays.Music
{
/// <summary>
/// A <see cref="CollectionDropdown"/> for use in the <see cref="NowPlayingOverlay"/>.
/// </summary>
public partial class NowPlayingCollectionDropdown : CollectionDropdown // TODO: class is now unused. if we decide this isn't coming back it can be nuked.
{
protected override bool ShowManageCollectionsItem => false;
protected override CollectionDropdownHeader CreateCollectionHeader() => new CollectionsHeader();
protected override CollectionDropdownMenu CreateCollectionMenu() => new CollectionsMenu();
private partial class CollectionsMenu : CollectionDropdownMenu
{
public CollectionsMenu()
{
Masking = true;
CornerRadius = 5;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
BackgroundColour = colours.Gray4;
SelectionColour = colours.Gray5;
HoverColour = colours.Gray6;
}
}
private partial class CollectionsHeader : CollectionDropdownHeader
{
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
BackgroundColour = colours.Gray4;
BackgroundColourHover = colours.Gray6;
}
public CollectionsHeader()
{
CornerRadius = 5;
Height = 30;
Chevron.Size = new Vector2(14);
Chevron.Margin = new MarginPadding(0);
Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 10, Right = 10 };
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Colour = Color4.Black.Opacity(0.3f),
Radius = 3,
Offset = new Vector2(0f, 1f),
};
}
}
}
}
+16 -18
View File
@@ -37,6 +37,9 @@ namespace osu.Game.Overlays
/// </summary>
private const double restart_cutoff_point = 5000;
public const double TRACK_FADE_IN_TIME = 250;
public const double TRACK_FADE_OUT_TIME = 100;
/// <summary>
/// Whether the user has requested the track to be paused. Use <see cref="IsPlaying"/> to determine whether the track is still playing.
/// </summary>
@@ -518,25 +521,20 @@ namespace osu.Game.Overlays
CurrentTrack = queuedTrack;
// At this point we may potentially be in an async context from tests. This is extremely dangerous but we have to make do for now.
// CurrentTrack is immediately updated above for situations where a immediate knowledge about the new track is required,
// but the mutation of the hierarchy is scheduled to avoid exceptions.
Schedule(() =>
{
lastTrack.VolumeTo(0, 500, Easing.Out).Expire();
lastTrack.VolumeTo(0, TRACK_FADE_OUT_TIME, Easing.Out).Expire();
if (queuedTrack == CurrentTrack)
{
AddInternal(queuedTrack);
queuedTrack.VolumeTo(0).Then().VolumeTo(1, 300, Easing.Out);
}
else
{
// If the track has changed since the call to changeTrack, it is safe to dispose the
// queued track rather than consume it.
queuedTrack.Dispose();
}
});
if (queuedTrack == CurrentTrack)
{
queuedTrack.Volume.Value = 0;
AddInternal(queuedTrack);
queuedTrack.Delay(50).VolumeTo(1, TRACK_FADE_IN_TIME, Easing.Out);
}
else
{
// If the track has changed since the call to changeTrack, it is safe to dispose the
// queued track rather than consume it.
queuedTrack.Dispose();
}
}
private DrawableTrack getQueuedTrack()
+2 -2
View File
@@ -37,7 +37,7 @@ namespace osu.Game.Overlays
private const float player_width = 400;
private const float player_height = 130;
private const float transition_length = 800;
private const float transition_length = 500;
private const float progress_height = 10;
private const float bottom_black_area_height = 55;
private const float margin = 10;
@@ -289,7 +289,7 @@ namespace osu.Game.Overlays
protected override void PopIn()
{
this.FadeIn(transition_length, Easing.OutQuint);
dragContainer.ScaleTo(1, transition_length, Easing.OutElastic);
dragContainer.ScaleTo(1, transition_length, Easing.OutElasticHalf);
}
protected override void PopOut()
+1 -1
View File
@@ -71,7 +71,7 @@ namespace osu.Game.Overlays
Button.State = Target > button_scroll_position || lastScrollTarget.Value != null ? Visibility.Visible : Visibility.Hidden;
}
protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = default)
protected override void OnUserScroll(double value, bool animated = true, double? distanceDecay = null)
{
base.OnUserScroll(value, animated, distanceDecay);
@@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input
{
var bindings = realm.All<RealmKeyBinding>()
.Where(b => b.RulesetName == null && b.Variant == null)
.Detach();
.AsEnumerable().Detach();
var actionsInSection = GlobalActionContainer.GetGlobalActionsFor(category).Cast<int>().ToHashSet();
return bindings.Where(kb => actionsInSection.Contains(kb.ActionInt));
@@ -62,7 +62,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input
/// <summary>
/// Based on ultrawide monitor configurations, plus a bit of lenience for users which are intentionally aiming for higher horizontal velocity.
/// </summary>
private const float largest_feasible_aspect_ratio = 23f / 9;
private const float largest_feasible_aspect_ratio = 32f / 9;
private readonly BindableNumber<float> aspectRatio = new BindableFloat(1)
{
@@ -67,13 +67,13 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing
public DifficultyHitObject Previous(int backwardsIndex)
{
int index = Index - (backwardsIndex + 1);
return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : default;
return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null;
}
public DifficultyHitObject Next(int forwardsIndex)
{
int index = Index + (forwardsIndex + 1);
return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : default;
return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null;
}
}
}
@@ -144,7 +144,7 @@ namespace osu.Game.Rulesets.Mods
}
}
public DifficultyBindableWithCurrent(float? defaultValue = default)
public DifficultyBindableWithCurrent(float? defaultValue = null)
: base(defaultValue)
{
}
@@ -57,8 +57,8 @@ namespace osu.Game.Rulesets.Mods
public BindableNumber<double> MinimumAccuracy { get; } = new BindableDouble
{
MinValue = 0.60,
MaxValue = 0.99,
Precision = 0.01,
MaxValue = 0.999,
Precision = 0.001,
Default = 0.9,
Value = 0.9,
};
+1
View File
@@ -46,6 +46,7 @@ namespace osu.Game.Rulesets.Mods
player.DimmableStoryboard.IgnoreUserSettings.Value = true;
player.BreakOverlay.Hide();
player.OverlayComponents.Hide();
}
public bool PerformFail() => false;
@@ -69,11 +69,9 @@ namespace osu.Game.Rulesets.Objects.Pooling
HitObject hitObject = entry.HitObject;
if (!entryMap.ContainsKey(hitObject))
if (!entryMap.Remove(hitObject))
throw new InvalidOperationException($@"The {nameof(HitObjectLifetimeEntry)} is not contained in this {nameof(HitObjectEntryManager)}.");
entryMap.Remove(hitObject);
// If the entry has a parent, unset it and remove the entry from the parents' children.
if (parentMap.Remove(entry, out var parent) && entryMap.TryGetValue(parent, out var parentEntry))
parentEntry.NestedEntries.Remove(entry);
+1 -1
View File
@@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.UI
applyRulesetMods(Mods, config);
loadObjects(cancellationToken ?? default);
loadObjects(cancellationToken ?? CancellationToken.None);
}
/// <summary>
@@ -153,7 +153,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms
/// </summary>
public readonly double Position;
public PositionMapping(double time, MultiplierControlPoint controlPoint = null, double position = default)
public PositionMapping(double time, MultiplierControlPoint controlPoint = null, double position = 0)
{
Time = time;
ControlPoint = controlPoint;
@@ -10,18 +10,28 @@ namespace osu.Game.Screens.Backgrounds
{
public partial class BackgroundScreenBlack : BackgroundScreen
{
public BackgroundScreenBlack()
private readonly double delayBeforeBlack;
private readonly Box box;
public BackgroundScreenBlack(double delayBeforeBlack = 0)
{
InternalChild = new Box
this.delayBeforeBlack = delayBeforeBlack;
InternalChild = box = new Box
{
Colour = Color4.Black,
RelativeSizeAxes = Axes.Both,
};
Alpha = 0;
}
public override void OnEntering(ScreenTransitionEvent e)
{
Show();
this
.Delay(delayBeforeBlack)
.FadeIn(200)
.OnComplete(_ => box.Hide());
}
}
}
@@ -1,36 +1,34 @@
// 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 System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Timing;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Screens.Edit;
using osu.Game.Storyboards.Drawables;
using osuTK.Graphics;
namespace osu.Game.Screens.Backgrounds
{
public partial class EditorBackgroundScreen : BackgroundScreen
{
private readonly EditorBeatmap editorBeatmap;
private readonly Container dimContainer;
private CancellationTokenSource? cancellationTokenSource;
private Bindable<float> dimLevel = null!;
private Bindable<bool> showStoryboard = null!;
private BeatmapBackground background = null!;
private Container storyboardContainer = null!;
private BeatmapBackgroundWithStoryboard? background;
private IFrameBasedClock? clockSource;
private readonly Container content;
private readonly Box blackBox;
// We retrieve IBindable<WorkingBeatmap> from our dependency cache instead of passing WorkingBeatmap directly into EditorBackgroundScreen.
// Otherwise, DummyWorkingBeatmap will be erroneously passed in whenever creating a new beatmap (since the Schedule() in the Editor that populates
@@ -40,40 +38,35 @@ namespace osu.Game.Screens.Backgrounds
public EditorBackgroundScreen(EditorBeatmap editorBeatmap)
{
this.editorBeatmap = editorBeatmap;
InternalChild = dimContainer = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
// This adds overdraw but makes transitions not suck.
// There's probably a better way to do this, but it's high effort.
blackBox = new Box
{
Colour = Color4.Black,
RelativeSizeAxes = Axes.Both,
},
content = new EditorSkinProvidingContainer(editorBeatmap)
{
RelativeSizeAxes = Axes.Both,
},
},
};
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
dimContainer.AddRange(createContent());
background = dimContainer.OfType<BeatmapBackground>().Single();
storyboardContainer = dimContainer.OfType<Container>().Single();
dimLevel = config.GetBindable<float>(OsuSetting.EditorDim);
showStoryboard = config.GetBindable<bool>(OsuSetting.EditorShowStoryboard);
}
private IEnumerable<Drawable> createContent() =>
[
new BeatmapBackground(beatmap.Value) { RelativeSizeAxes = Axes.Both, },
// one reason for this kooky container nesting being here is that the storyboard needs a custom clock
// but also needs it on an isolated-enough level that doesn't break screen stack expiry logic (which happens if the clock was put on `this`),
// or doesn't make it literally impossible to fade the storyboard in/out in real time (which happens if the fade transforms were to be applied directly to the storyboard).
// another is that we need `EditorSkinProvidingContainer` so that storyboard sample lookups succeed.
new EditorSkinProvidingContainer(editorBeatmap)
{
RelativeSizeAxes = Axes.Both,
Child = new DrawableStoryboard(beatmap.Value.Storyboard)
{
Clock = clockSource ?? Clock,
}
}
];
content.Child = createContent();
updateState(withAnimation: false);
}
protected override void LoadComplete()
{
@@ -81,38 +74,45 @@ namespace osu.Game.Screens.Backgrounds
dimLevel.BindValueChanged(_ => dimContainer.FadeColour(OsuColour.Gray(1 - dimLevel.Value), 500, Easing.OutQuint), true);
showStoryboard.BindValueChanged(_ => updateState());
updateState(0);
updateState(withAnimation: false);
}
private void updateState(double duration = 500)
public override void OnEntering(ScreenTransitionEvent e)
{
storyboardContainer.FadeTo(showStoryboard.Value ? 1 : 0, duration, Easing.OutQuint);
// yes, this causes overdraw, but is also a (crude) fix for bad-looking transitions on screen entry
// caused by the previous background on the background stack poking out from under this one and then instantly fading out
background.FadeColour(beatmap.Value.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint);
base.OnEntering(e);
blackBox.LifetimeEnd = LatestTransformEndTime;
}
public void ChangeClockSource(IFrameBasedClock frameBasedClock)
public override bool OnExiting(ScreenExitEvent e)
{
clockSource = frameBasedClock;
if (IsLoaded)
storyboardContainer.Child.Clock = frameBasedClock;
// The storyboard will do weird things with clock time changing on exit, so let's just hide it instead.
background?.UnloadStoryboard();
return base.OnExiting(e);
}
public void RefreshBackground()
public void RefreshBackgroundAsync()
{
cancellationTokenSource?.Cancel();
LoadComponentsAsync(createContent(), loaded =>
LoadComponentAsync(createContent(), loaded =>
{
dimContainer.Clear();
dimContainer.AddRange(loaded);
background = dimContainer.OfType<BeatmapBackground>().Single();
storyboardContainer = dimContainer.OfType<Container>().Single();
updateState(0);
content.Child = loaded;
updateState(withAnimation: false);
}, (cancellationTokenSource = new CancellationTokenSource()).Token);
}
private Drawable createContent() => background = new BeatmapBackgroundWithStoryboard(beatmap.Value)
{
RelativeSizeAxes = Axes.Both,
StoryboardLoaded = () => updateState(withAnimation: false)
};
private void updateState(bool withAnimation = true)
{
background?.Storyboard.FadeTo(showStoryboard.Value ? 1 : 0, withAnimation ? 500 : 0, Easing.OutQuint);
}
public override bool Equals(BackgroundScreen? other)
{
if (other is not EditorBackgroundScreen otherBeatmapBackground)
@@ -57,7 +57,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
case EffectControlPoint:
if (!showScrollSpeed)
return;
continue;
AddInternal(new ControlPointVisualisation(point)
{
-13
View File
@@ -472,8 +472,6 @@ namespace osu.Game.Screens.Edit
changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
editorBackgroundDim.BindValueChanged(_ => setUpBackground());
}
[Resolved]
@@ -845,26 +843,15 @@ namespace osu.Game.Screens.Edit
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
setUpBackground();
setUpTrack(seekToStart: true);
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
setUpBackground();
setUpTrack();
}
private void setUpBackground()
{
ApplyToBackground(b =>
{
var editorBackground = (EditorBackgroundScreen)b;
editorBackground.ChangeClockSource(clock);
});
}
public override bool OnExiting(ScreenExitEvent e)
{
currentScreen?.OnExiting(e);
@@ -113,7 +113,7 @@ namespace osu.Game.Screens.Edit.Setup
(metadata, name) => metadata.BackgroundFile = name);
headerBackground.UpdateBackground();
editor?.ApplyToBackground(bg => ((EditorBackgroundScreen)bg).RefreshBackground());
editor?.ApplyToBackground(bg => ((EditorBackgroundScreen)bg).RefreshBackgroundAsync());
return true;
}
+7 -1
View File
@@ -38,7 +38,13 @@ namespace osu.Game.Screens.Menu
if (notifications.HasOngoingOperations)
{
var ongoingOperations = notifications.OngoingOperations.ToArray();
string ongoingOperationsText = ongoingOperations.Take(10).Aggregate(string.Empty, (current, n) => current + $"{n.Text} ({n.Progress:0%})\n");
string ongoingOperationsText = ongoingOperations.Take(10).Aggregate(string.Empty, (current, n) =>
{
if (n.Progress > 0)
return current + $"{n.Text} ({n.Progress:0%})\n";
return current + $"{n.Text}\n";
});
LocalisableString ongoingOperationsLocalisableString;
+1
View File
@@ -429,6 +429,7 @@ namespace osu.Game.Screens.Menu
}, () =>
{
holdToExitGameOverlay.Abort();
Game.CancelRestartOnExit();
}));
}
@@ -1,88 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Components
{
/// <summary>
/// A header used in the multiplayer interface which shows text / details beneath a line.
/// </summary>
public partial class OverlinedHeader : CompositeDrawable
{
private bool showLine = true;
public bool ShowLine
{
get => showLine;
set
{
showLine = value;
line.Alpha = value ? 1 : 0;
}
}
public Bindable<string> Details = new Bindable<string>();
private readonly Circle line;
private readonly OsuSpriteText details;
public OverlinedHeader(LocalisableString title)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Margin = new MarginPadding { Bottom = 5 };
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
line = new Circle
{
RelativeSizeAxes = Axes.X,
Height = 2,
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new Drawable[]
{
new OsuSpriteText
{
Text = title,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold)
},
details = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold)
},
}
},
}
};
Details.BindValueChanged(val => details.Text = val.NewValue);
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
line.Colour = colours.Yellow;
details.Colour = colours.Yellow;
}
}
}
@@ -3,19 +3,20 @@
using System.ComponentModel;
using osu.Framework.Allocation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
namespace osu.Game.Screens.OnlinePlay.Components
{
public partial class OverlinedPlaylistHeader : OverlinedHeader
public partial class PlaylistHeader : SectionHeader
{
private readonly Room room;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
public OverlinedPlaylistHeader(Room room)
public PlaylistHeader(Room room)
: base("Playlist")
{
this.room = room;
@@ -36,7 +37,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
}
private void updateDuration()
=> Details.Value = room.Playlist.GetTotalDuration(rulesets);
=> DetailsText.Value = $"{room.Playlist.GetTotalDuration(rulesets)}";
protected override void Dispose(bool isDisposing)
{
@@ -252,7 +252,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge
{
new Drawable[]
{
new SectionHeader("Chat")
new SectionHeader(OnlinePlayStrings.Chat)
},
[new MatchChatDisplay(room) { RelativeSizeAxes = Axes.Both }]
},
@@ -1,7 +1,9 @@
// 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.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
@@ -10,13 +12,18 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay.Card;
using osu.Game.Screens.Select;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
@@ -40,6 +47,21 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
[Resolved]
private OverlayColourProvider overlayColours { get; set; } = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private Bindable<WorkingBeatmap> globalBeatmap { get; set; } = null!;
[Resolved]
private Bindable<RulesetInfo> globalRuleset { get; set; } = null!;
[Resolved]
private Bindable<IReadOnlyList<Mod>> globalMods { get; set; } = null!;
private Container<RankedPlayCard> cardColumn = null!;
private Drawable separator = null!;
private Drawable detailsColumn = null!;
@@ -128,6 +150,23 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
];
}
protected override void LoadComplete()
{
base.LoadComplete();
MultiplayerPlaylistItem item = Client.Room!.CurrentPlaylistItem;
RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!;
Ruleset rulesetInstance = ruleset.CreateInstance();
BeatmapInfo? localBeatmap = beatmapManager.QueryOnlineBeatmapId(item.BeatmapID);
globalBeatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap);
globalRuleset.Value = ruleset;
globalMods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
Client.ChangeState(MultiplayerUserState.Ready).FireAndForget();
}
public override void OnEntering(RankedPlaySubScreen? previous)
{
base.OnEntering(previous);
@@ -0,0 +1,98 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
{
public partial class RankedPlayBeatmapAvailabilityTracker : OnlinePlayBeatmapAvailabilityTracker
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
[Resolved]
private BeatmapModelDownloader beatmapDownloader { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private CancellationTokenSource? downloadCheckCancellation;
private int? lastDownloadCheckedBeatmapId;
protected override void LoadComplete()
{
base.LoadComplete();
Availability.BindValueChanged(onBeatmapAvailabilityChanged);
client.SettingsChanged += onSettingsChanged;
onSettingsChanged(client.Room!.Settings);
}
private void onSettingsChanged(MultiplayerRoomSettings settings)
{
PlaylistItem.Value = new PlaylistItem(client.Room!.CurrentPlaylistItem);
checkForAutomaticDownload(client.Room!.CurrentPlaylistItem);
}
private void onBeatmapAvailabilityChanged(ValueChangedEvent<BeatmapAvailability> availability)
{
client.ChangeBeatmapAvailability(availability.NewValue).FireAndForget();
}
private void checkForAutomaticDownload(MultiplayerPlaylistItem item)
{
// This method is called every time anything changes in the room.
// This could result in download requests firing far too often, when we only expect them to fire once per beatmap.
//
// Without this check, we would see especially egregious behaviour when a user has hit the download rate limit.
if (lastDownloadCheckedBeatmapId == item.BeatmapID)
return;
lastDownloadCheckedBeatmapId = item.BeatmapID;
downloadCheckCancellation?.Cancel();
if (beatmapManager.IsAvailableLocally(new APIBeatmap { OnlineID = item.BeatmapID }))
return;
// 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.
beatmapLookupCache
.GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token)
.ContinueWith(resolved => Schedule(() =>
{
APIBeatmapSet? beatmapSet = resolved.GetResultSafely()?.BeatmapSet;
if (beatmapSet == null)
return;
beatmapDownloader.Download(beatmapSet, config.Get<bool>(OsuSetting.PreferNoVideo));
}));
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (client.IsNotNull())
client.SettingsChanged -= onSettingsChanged;
}
}
}
@@ -3,12 +3,10 @@
using System.Diagnostics;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
@@ -16,12 +14,8 @@ using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Cursor;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.RankedPlay;
using osu.Game.Online.Rooms;
@@ -57,36 +51,18 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
public override float BackgroundParallaxAmount => 0;
[Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))]
private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker();
[Resolved]
private MultiplayerClient client { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
[Resolved]
private BeatmapModelDownloader beatmapDownloader { get; set; } = null!;
[Resolved]
private IDialogOverlay dialogOverlay { get; set; } = null!;
[Resolved]
private AudioManager audio { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
[Resolved]
private PreviewTrackManager previewTrackManager { get; set; } = null!;
@@ -103,8 +79,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
private IBindable<RankedPlayStage> stage = null!;
private Sample? sampleStart;
private CancellationTokenSource? downloadCheckCancellation;
private int? lastDownloadCheckedBeatmapId;
private readonly Bindable<Visibility> cornerPieceVisibility = new Bindable<Visibility>();
private readonly Bindable<bool> showBeatmapBackground = new Bindable<bool>();
@@ -125,7 +99,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
InternalChildren = new Drawable[]
{
matchInfo = new RankedPlayMatchInfo(),
beatmapAvailabilityTracker,
new RankedPlayBeatmapAvailabilityTracker(),
new GlobalScrollAdjustsVolume(),
new PopoverContainer
{
@@ -176,11 +150,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
client.RoomUpdated += onRoomUpdated;
client.UserStateChanged += onUserStateChanged;
client.SettingsChanged += onSettingsChanged;
client.LoadRequested += onLoadRequested;
beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true);
int localUserId = api.LocalUser.Value.OnlineID;
int opponentUserId = ((RankedPlayRoomState)client.Room!.MatchState!).Users.Keys.Single(it => it != localUserId);
@@ -256,24 +227,9 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
this.MakeCurrent();
}
private void onSettingsChanged(MultiplayerRoomSettings _) => Scheduler.Add(() =>
{
checkForAutomaticDownload();
updateGameplayState();
});
private void onLoadRequested() => Scheduler.Add(() =>
{
updateGameplayState();
if (Beatmap.IsDefault)
{
Logger.Log("Aborting gameplay start - beatmap not downloaded.");
return;
}
sampleStart?.Play();
this.Push(new MultiplayerPlayerLoader(() => new ScreenGameplay(new Room(room), new PlaylistItem(client.Room!.CurrentPlaylistItem), room.Users.ToArray())));
});
@@ -329,92 +285,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
}
}
private void onBeatmapAvailabilityChanged(ValueChangedEvent<BeatmapAvailability> e) => Scheduler.Add(() =>
{
if (client.Room == null || client.LocalUser == null)
return;
client.ChangeBeatmapAvailability(e.NewValue).FireAndForget();
switch (e.NewValue.State)
{
case DownloadState.NotDownloaded:
case DownloadState.LocallyAvailable:
updateGameplayState();
break;
}
});
private void updateGameplayState()
{
MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem;
if (item.Expired)
return;
RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!;
Ruleset rulesetInstance = ruleset.CreateInstance();
// Update global gameplay state to correspond to the new selection.
// Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info
var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID);
if (localBeatmap != null)
{
Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap);
Ruleset.Value = ruleset;
Mods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
// Notify the server that the beatmap has been set and that we are ready to start gameplay.
if (client.LocalUser!.State == MultiplayerUserState.Idle)
client.ChangeState(MultiplayerUserState.Ready).FireAndForget();
}
else
{
// Notify the server that we don't have the beatmap.
if (client.LocalUser!.State == MultiplayerUserState.Ready)
client.ChangeState(MultiplayerUserState.Idle).FireAndForget();
}
client.ChangeBeatmapAvailability(beatmapAvailabilityTracker.Availability.Value).FireAndForget();
}
private void checkForAutomaticDownload()
{
if (client.Room == null)
return;
MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem;
// This method is called every time anything changes in the room.
// This could result in download requests firing far too often, when we only expect them to fire once per beatmap.
//
// Without this check, we would see especially egregious behaviour when a user has hit the download rate limit.
if (lastDownloadCheckedBeatmapId == item.BeatmapID)
return;
lastDownloadCheckedBeatmapId = item.BeatmapID;
downloadCheckCancellation?.Cancel();
if (beatmapManager.IsAvailableLocally(new APIBeatmap { OnlineID = item.BeatmapID }))
return;
// 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.
beatmapLookupCache
.GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token)
.ContinueWith(resolved => Schedule(() =>
{
APIBeatmapSet? beatmapSet = resolved.GetResultSafely()?.BeatmapSet;
if (beatmapSet == null)
return;
beatmapDownloader.Download(beatmapSet, config.Get<bool>(OsuSetting.PreferNoVideo));
}));
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
@@ -527,7 +397,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.RankedPlay
{
client.RoomUpdated -= onRoomUpdated;
client.UserStateChanged -= onUserStateChanged;
client.SettingsChanged -= onSettingsChanged;
client.LoadRequested -= onLoadRequested;
base.Dispose(isDisposing);
@@ -83,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
initialItem = itemToEdit ?? room.Playlist.LastOrDefault();
Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING };
LeftPadding = new MarginPadding { Top = CORNER_RADIUS_HIDE_OFFSET + Header.HEIGHT };
TopPadding = Header.HEIGHT - 10;
freeModSelect = new FreeModSelectOverlay
{
@@ -3,6 +3,7 @@
using System;
using System.Linq;
using Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@@ -18,9 +19,12 @@ using osu.Framework.Screens;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
@@ -145,6 +149,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private FillFlowContainer userStyleSection = null!;
private Container<DrawableRoomPlaylistItem> userStyleDisplayContainer = null!;
private MatchChatDisplay chat = null!;
private Sample? sampleStart;
private IDisposable? userModsSelectOverlayRegistration;
@@ -221,7 +227,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
new GridContainer
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(content_padding),
Padding = new MarginPadding(content_padding) { Top = 10 },
ColumnDimensions = new[]
{
new Dimension(),
@@ -273,7 +279,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
new Drawable[]
{
new OverlinedHeader("Beatmap queue")
new SectionHeader(OnlinePlayStrings.MultiplayerBeatmapQueue)
},
new Drawable[]
{
@@ -305,7 +311,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Alpha = 0,
Children = new Drawable[]
{
new OverlinedHeader("Extra mods"),
new SectionHeader("Extra mods"),
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
@@ -343,7 +349,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Alpha = 0,
Children = new Drawable[]
{
new OverlinedHeader("Difficulty"),
new SectionHeader(OnlinePlayStrings.Difficulty),
userStyleDisplayContainer = new Container<DrawableRoomPlaylistItem>
{
RelativeSizeAxes = Axes.X,
@@ -366,11 +372,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
new Drawable[]
{
new OverlinedHeader("Chat")
new SectionHeader(OnlinePlayStrings.Chat)
},
new Drawable[]
{
new MatchChatDisplay(room)
chat = new MatchChatDisplay(room)
{
RelativeSizeAxes = Axes.Both
}
@@ -431,6 +437,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
client.UserStyleChanged += onUserStyleChanged;
client.UserModsChanged += onUserModsChanged;
client.LoadRequested += onLoadRequested;
client.MatchEvent += onMatchEvent;
beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true);
@@ -594,6 +601,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
}
private void onMatchEvent(MatchServerEvent ev)
{
switch (ev)
{
case RollEvent rollEvent:
var user = client.Room?.Users.SingleOrDefault(u => u.UserID == rollEvent.UserID)?.User ?? new APIUser { Username = "Unknown user" };
string text = $"{user.Username} rolled {"point".ToQuantity(rollEvent.Result)} out of {rollEvent.Max}.";
chat.Channel.Value?.AddNewMessages(new InfoMessage(text));
break;
}
}
/// <summary>
/// Responds to changes in the local user's beatmap availability to notify the server and prepare the gameplay session.
/// </summary>
@@ -282,7 +282,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty<Mod>() : user.Mods.Select(m => m.ToMod(userRuleset)).ToList());
}
userStateDisplay.UpdateStatus(user.State, user.BeatmapAvailability);
userStateDisplay.UpdateStatus(user);
if (user.BeatmapAvailability.State == DownloadState.LocallyAvailable && user.State != MultiplayerUserState.Spectating)
{
@@ -2,13 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Screens.OnlinePlay.Components;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
{
public partial class ParticipantsListHeader : OverlinedHeader
public partial class ParticipantsListHeader : SectionHeader
{
[Resolved]
private MultiplayerClient client { get; set; } = null!;
@@ -26,7 +26,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
if (room == null)
return;
Details.Value = room.Users.Count.ToString();
DetailsText.Value = $"{room.Users.Count}";
}
}
}

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