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

Merge branch 'master' into rework-multiplayer-test-scenes

This commit is contained in:
smoogipoo 2021-06-29 15:39:59 +09:00
commit 92fa99700e
101 changed files with 2089 additions and 566 deletions

View File

@ -15,6 +15,7 @@ jobs:
- { prettyname: macOS, fullname: macos-latest } - { prettyname: macOS, fullname: macos-latest }
- { prettyname: Linux, fullname: ubuntu-latest } - { prettyname: Linux, fullname: ubuntu-latest }
threadingMode: ['SingleThread', 'MultiThreaded'] threadingMode: ['SingleThread', 'MultiThreaded']
timeout-minutes: 60
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2

View File

@ -21,6 +21,7 @@ jobs:
- { prettyname: macOS } - { prettyname: macOS }
- { prettyname: Linux } - { prettyname: Linux }
threadingMode: ['SingleThread', 'MultiThreaded'] threadingMode: ['SingleThread', 'MultiThreaded']
timeout-minutes: 5
steps: steps:
- name: Annotate CI run with test results - name: Annotate CI run with test results
uses: dorny/test-reporter@v1.4.2 uses: dorny/test-reporter@v1.4.2

View File

@ -1,3 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> <Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Realm /> <Realm DisableAnalytics="true" />
</Weavers> </Weavers>

View File

@ -52,7 +52,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.622.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2021.628.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Transitive Dependencies"> <ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. --> <!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->

View File

@ -6,8 +6,8 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Game.Rulesets.Catch.UI;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.UI;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
@ -31,10 +31,23 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved] [Resolved]
private OsuConfigManager config { get; set; } private OsuConfigManager config { get; set; }
private Container<CaughtObject> droppedObjectContainer; [Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
private readonly Container trailContainer;
private TestCatcher catcher; private TestCatcher catcher;
public TestSceneCatcher()
{
Add(trailContainer = new Container
{
Anchor = Anchor.Centre,
Depth = -1
});
Add(droppedObjectContainer = new DroppedObjectContainer());
}
[SetUp] [SetUp]
public void SetUp() => Schedule(() => public void SetUp() => Schedule(() =>
{ {
@ -43,20 +56,13 @@ namespace osu.Game.Rulesets.Catch.Tests
CircleSize = 0, CircleSize = 0,
}; };
var trailContainer = new Container(); if (catcher != null)
droppedObjectContainer = new Container<CaughtObject>(); Remove(catcher);
catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty);
Child = new Container Add(catcher = new TestCatcher(trailContainer, difficulty)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre
Children = new Drawable[] });
{
trailContainer,
droppedObjectContainer,
catcher
}
};
}); });
[Test] [Test]
@ -293,8 +299,8 @@ namespace osu.Game.Rulesets.Catch.Tests
{ {
public IEnumerable<CaughtObject> CaughtObjects => this.ChildrenOfType<CaughtObject>(); public IEnumerable<CaughtObject> CaughtObjects => this.ChildrenOfType<CaughtObject>();
public TestCatcher(Container trailsTarget, Container<CaughtObject> droppedObjectTarget, BeatmapDifficulty difficulty) public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty)
: base(trailsTarget, droppedObjectTarget, difficulty) : base(trailsTarget, difficulty)
{ {
} }
} }

View File

@ -6,7 +6,6 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Framework.Utils; using osu.Framework.Utils;
@ -97,18 +96,12 @@ namespace osu.Game.Rulesets.Catch.Tests
SetContents(_ => SetContents(_ =>
{ {
var droppedObjectContainer = new Container<CaughtObject>
{
RelativeSizeAxes = Axes.Both
};
return new CatchInputManager(catchRuleset) return new CatchInputManager(catchRuleset)
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new Drawable[] Children = new Drawable[]
{ {
droppedObjectContainer, new TestCatcherArea(beatmapDifficulty)
new TestCatcherArea(droppedObjectContainer, beatmapDifficulty)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
@ -126,9 +119,13 @@ namespace osu.Game.Rulesets.Catch.Tests
private class TestCatcherArea : CatcherArea private class TestCatcherArea : CatcherArea
{ {
public TestCatcherArea(Container<CaughtObject> droppedObjectContainer, BeatmapDifficulty beatmapDifficulty) [Cached]
: base(droppedObjectContainer, beatmapDifficulty) private readonly DroppedObjectContainer droppedObjectContainer;
public TestCatcherArea(BeatmapDifficulty beatmapDifficulty)
: base(beatmapDifficulty)
{ {
AddInternal(droppedObjectContainer = new DroppedObjectContainer());
} }
public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1);

View File

@ -118,11 +118,10 @@ namespace osu.Game.Rulesets.Catch.Tests
AddStep("create hyper-dashing catcher", () => AddStep("create hyper-dashing catcher", () =>
{ {
Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container<CaughtObject>()) Child = setupSkinHierarchy(catcherArea = new TestCatcherArea
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre
Scale = new Vector2(4f),
}, skin); }, skin);
}); });
@ -206,5 +205,18 @@ namespace osu.Game.Rulesets.Catch.Tests
{ {
} }
} }
private class TestCatcherArea : CatcherArea
{
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
public TestCatcherArea()
{
Scale = new Vector2(4f);
AddInternal(droppedObjectContainer = new DroppedObjectContainer());
}
}
} }
} }

View File

@ -3,7 +3,6 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Objects.Drawables;
@ -27,6 +26,9 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
public const float CENTER_X = WIDTH / 2; public const float CENTER_X = WIDTH / 2;
[Cached]
private readonly DroppedObjectContainer droppedObjectContainer;
internal readonly CatcherArea CatcherArea; internal readonly CatcherArea CatcherArea;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
@ -35,12 +37,7 @@ namespace osu.Game.Rulesets.Catch.UI
public CatchPlayfield(BeatmapDifficulty difficulty) public CatchPlayfield(BeatmapDifficulty difficulty)
{ {
var droppedObjectContainer = new Container<CaughtObject> CatcherArea = new CatcherArea(difficulty)
{
RelativeSizeAxes = Axes.Both,
};
CatcherArea = new CatcherArea(droppedObjectContainer, difficulty)
{ {
Anchor = Anchor.BottomLeft, Anchor = Anchor.BottomLeft,
Origin = Anchor.TopLeft, Origin = Anchor.TopLeft,
@ -48,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.UI
InternalChildren = new[] InternalChildren = new[]
{ {
droppedObjectContainer, droppedObjectContainer = new DroppedObjectContainer(),
CatcherArea.MovableCatcher.CreateProxiedContent(), CatcherArea.MovableCatcher.CreateProxiedContent(),
HitObjectContainer.CreateProxy(), HitObjectContainer.CreateProxy(),
// This ordering (`CatcherArea` before `HitObjectContainer`) is important to // This ordering (`CatcherArea` before `HitObjectContainer`) is important to

View File

@ -79,7 +79,8 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary> /// <summary>
/// Contains objects dropped from the plate. /// Contains objects dropped from the plate.
/// </summary> /// </summary>
private readonly Container<CaughtObject> droppedObjectTarget; [Resolved]
private DroppedObjectContainer droppedObjectTarget { get; set; }
public CatcherAnimationState CurrentState public CatcherAnimationState CurrentState
{ {
@ -134,10 +135,9 @@ namespace osu.Game.Rulesets.Catch.UI
private readonly DrawablePool<CaughtBanana> caughtBananaPool; private readonly DrawablePool<CaughtBanana> caughtBananaPool;
private readonly DrawablePool<CaughtDroplet> caughtDropletPool; private readonly DrawablePool<CaughtDroplet> caughtDropletPool;
public Catcher([NotNull] Container trailsTarget, [NotNull] Container<CaughtObject> droppedObjectTarget, BeatmapDifficulty difficulty = null) public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null)
{ {
this.trailsTarget = trailsTarget; this.trailsTarget = trailsTarget;
this.droppedObjectTarget = droppedObjectTarget;
Origin = Anchor.TopCentre; Origin = Anchor.TopCentre;

View File

@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
private int currentDirection; private int currentDirection;
public CatcherArea(Container<CaughtObject> droppedObjectContainer, BeatmapDifficulty difficulty = null) public CatcherArea(BeatmapDifficulty difficulty = null)
{ {
Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE);
Children = new Drawable[] Children = new Drawable[]
@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Catch.UI
Margin = new MarginPadding { Bottom = 350f }, Margin = new MarginPadding { Bottom = 350f },
X = CatchPlayfield.CENTER_X X = CatchPlayfield.CENTER_X
}, },
MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X }, MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X },
}; };
} }

View File

@ -37,6 +37,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override void FreeAfterUse() protected override void FreeAfterUse()
{ {
ClearTransforms(); ClearTransforms();
Alpha = 1;
base.FreeAfterUse(); base.FreeAfterUse();
} }
} }

View File

@ -0,0 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Catch.Objects.Drawables;
namespace osu.Game.Rulesets.Catch.UI
{
public class DroppedObjectContainer : Container<CaughtObject>
{
public DroppedObjectContainer()
{
RelativeSizeAxes = Axes.Both;
}
}
}

View File

@ -3,6 +3,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Configuration;
@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Mania
private class TimeSlider : OsuSliderBar<double> private class TimeSlider : OsuSliderBar<double>
{ {
public override string TooltipText => Current.Value.ToString("N0") + "ms"; public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
} }
} }
} }

View File

@ -13,6 +13,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
@ -283,6 +284,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
} }
} }
public string TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; public LocalisableString TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty;
} }
} }

View File

@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Osu.Utils;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Mods namespace osu.Game.Rulesets.Osu.Mods
@ -23,15 +24,6 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public override string Description => "It never gets boring!"; public override string Description => "It never gets boring!";
// The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
// The closer the hit objects draw to the border, the sharper the turn
private const float playfield_edge_ratio = 0.375f;
private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio;
private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio;
private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2;
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
private Random rng; private Random rng;
@ -113,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Mods
distanceToPrev * (float)Math.Sin(current.AngleRad) distanceToPrev * (float)Math.Sin(current.AngleRad)
); );
posRelativeToPrev = getRotatedVector(previous.EndPositionRandomised, posRelativeToPrev); posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(previous.EndPositionRandomised, posRelativeToPrev);
current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
@ -185,73 +177,6 @@ namespace osu.Game.Rulesets.Osu.Mods
} }
} }
/// <summary>
/// Determines the position of the current hit object relative to the previous one.
/// </summary>
/// <returns>The position of the current hit object relative to the previous one</returns>
private Vector2 getRotatedVector(Vector2 prevPosChanged, Vector2 posRelativeToPrev)
{
var relativeRotationDistance = 0f;
if (prevPosChanged.X < playfield_middle.X)
{
relativeRotationDistance = Math.Max(
(border_distance_x - prevPosChanged.X) / border_distance_x,
relativeRotationDistance
);
}
else
{
relativeRotationDistance = Math.Max(
(prevPosChanged.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x,
relativeRotationDistance
);
}
if (prevPosChanged.Y < playfield_middle.Y)
{
relativeRotationDistance = Math.Max(
(border_distance_y - prevPosChanged.Y) / border_distance_y,
relativeRotationDistance
);
}
else
{
relativeRotationDistance = Math.Max(
(prevPosChanged.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y,
relativeRotationDistance
);
}
return rotateVectorTowardsVector(posRelativeToPrev, playfield_middle - prevPosChanged, relativeRotationDistance / 2);
}
/// <summary>
/// Rotates vector "initial" towards vector "destinantion"
/// </summary>
/// <param name="initial">Vector to rotate to "destination"</param>
/// <param name="destination">Vector "initial" should be rotated to</param>
/// <param name="relativeDistance">The angle the vector should be rotated relative to the difference between the angles of the the two vectors.</param>
/// <returns>Resulting vector</returns>
private Vector2 rotateVectorTowardsVector(Vector2 initial, Vector2 destination, float relativeDistance)
{
var initialAngleRad = Math.Atan2(initial.Y, initial.X);
var destAngleRad = Math.Atan2(destination.Y, destination.X);
var diff = destAngleRad - initialAngleRad;
while (diff < -Math.PI) diff += 2 * Math.PI;
while (diff > Math.PI) diff -= 2 * Math.PI;
var finalAngleRad = initialAngleRad + relativeDistance * diff;
return new Vector2(
initial.Length * (float)Math.Cos(finalAngleRad),
initial.Length * (float)Math.Sin(finalAngleRad)
);
}
private class RandomObjectInfo private class RandomObjectInfo
{ {
public float AngleRad { get; set; } public float AngleRad { get; set; }

View File

@ -0,0 +1,104 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Osu.UI;
using osuTK;
namespace osu.Game.Rulesets.Osu.Utils
{
public static class OsuHitObjectGenerationUtils
{
// The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle.
// The closer the hit objects draw to the border, the sharper the turn
private const float playfield_edge_ratio = 0.375f;
private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio;
private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio;
private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2;
/// <summary>
/// Rotate a hit object away from the playfield edge, while keeping a constant distance
/// from the previous object.
/// </summary>
/// <remarks>
/// The extent of rotation depends on the position of the hit object. Hit objects
/// closer to the playfield edge will be rotated to a larger extent.
/// </remarks>
/// <param name="prevObjectPos">Position of the previous hit object.</param>
/// <param name="posRelativeToPrev">Position of the hit object to be rotated, relative to the previous hit object.</param>
/// <param name="rotationRatio">
/// The extent of rotation.
/// 0 means the hit object is never rotated.
/// 1 means the hit object will be fully rotated towards playfield center when it is originally at playfield edge.
/// </param>
/// <returns>The new position of the hit object, relative to the previous one.</returns>
public static Vector2 RotateAwayFromEdge(Vector2 prevObjectPos, Vector2 posRelativeToPrev, float rotationRatio = 0.5f)
{
var relativeRotationDistance = 0f;
if (prevObjectPos.X < playfield_middle.X)
{
relativeRotationDistance = Math.Max(
(border_distance_x - prevObjectPos.X) / border_distance_x,
relativeRotationDistance
);
}
else
{
relativeRotationDistance = Math.Max(
(prevObjectPos.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x,
relativeRotationDistance
);
}
if (prevObjectPos.Y < playfield_middle.Y)
{
relativeRotationDistance = Math.Max(
(border_distance_y - prevObjectPos.Y) / border_distance_y,
relativeRotationDistance
);
}
else
{
relativeRotationDistance = Math.Max(
(prevObjectPos.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y,
relativeRotationDistance
);
}
return RotateVectorTowardsVector(
posRelativeToPrev,
playfield_middle - prevObjectPos,
Math.Min(1, relativeRotationDistance * rotationRatio)
);
}
/// <summary>
/// Rotates vector "initial" towards vector "destination".
/// </summary>
/// <param name="initial">The vector to be rotated.</param>
/// <param name="destination">The vector that "initial" should be rotated towards.</param>
/// <param name="rotationRatio">How much "initial" should be rotated. 0 means no rotation. 1 means "initial" is fully rotated to equal "destination".</param>
/// <returns>The rotated vector.</returns>
public static Vector2 RotateVectorTowardsVector(Vector2 initial, Vector2 destination, float rotationRatio)
{
var initialAngleRad = MathF.Atan2(initial.Y, initial.X);
var destAngleRad = MathF.Atan2(destination.Y, destination.X);
var diff = destAngleRad - initialAngleRad;
while (diff < -MathF.PI) diff += 2 * MathF.PI;
while (diff > MathF.PI) diff -= 2 * MathF.PI;
var finalAngleRad = initialAngleRad + rotationRatio * diff;
return new Vector2(
initial.Length * MathF.Cos(finalAngleRad),
initial.Length * MathF.Sin(finalAngleRad)
);
}
}
}

View File

@ -19,7 +19,9 @@ using osu.Game.Database;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring;
using osu.Game.Tests.Resources; using osu.Game.Tests.Resources;
using osu.Game.Tests.Scores.IO;
using osu.Game.Users; using osu.Game.Users;
using SharpCompress.Archives; using SharpCompress.Archives;
using SharpCompress.Archives.Zip; using SharpCompress.Archives.Zip;
@ -185,13 +187,62 @@ namespace osu.Game.Tests.Beatmaps.IO
} }
} }
private string hashFile(string filename) [Test]
public async Task TestImportThenImportWithChangedHashedFile()
{ {
using (var s = File.OpenRead(filename)) using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest)))
return s.ComputeMD5Hash(); {
try
{
var osu = LoadOsuIntoHost(host);
var temp = TestResources.GetTestBeatmapForImport();
string extractedFolder = $"{temp}_extracted";
Directory.CreateDirectory(extractedFolder);
try
{
var imported = await LoadOszIntoOsu(osu);
await createScoreForBeatmap(osu, imported.Beatmaps.First());
using (var zip = ZipArchive.Open(temp))
zip.WriteToDirectory(extractedFolder);
// arbitrary write to hashed file
// this triggers the special BeatmapManager.PreImport deletion/replacement flow.
using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).AppendText())
await sw.WriteLineAsync("// changed");
using (var zip = ZipArchive.Create())
{
zip.AddAllFromDirectory(extractedFolder);
zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate));
}
var importedSecondTime = await osu.Dependencies.Get<BeatmapManager>().Import(new ImportTask(temp));
ensureLoaded(osu);
// check the newly "imported" beatmap is not the original.
Assert.IsTrue(imported.ID != importedSecondTime.ID);
Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID);
}
finally
{
Directory.Delete(extractedFolder, true);
}
}
finally
{
host.Exit();
}
}
} }
[Test] [Test]
[Ignore("intentionally broken by import optimisations")]
public async Task TestImportThenImportWithChangedFile() public async Task TestImportThenImportWithChangedFile()
{ {
using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest)))
@ -294,6 +345,7 @@ namespace osu.Game.Tests.Beatmaps.IO
} }
[Test] [Test]
[Ignore("intentionally broken by import optimisations")]
public async Task TestImportCorruptThenImport() public async Task TestImportCorruptThenImport()
{ {
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
@ -439,12 +491,11 @@ namespace osu.Game.Tests.Beatmaps.IO
} }
} }
[TestCase(true)] [Test]
[TestCase(false)] public async Task TestImportThenDeleteThenImportWithOnlineIDsMissing()
public async Task TestImportThenDeleteThenImportWithOnlineIDMismatch(bool set)
{ {
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-{set}")) using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}"))
{ {
try try
{ {
@ -452,10 +503,8 @@ namespace osu.Game.Tests.Beatmaps.IO
var imported = await LoadOszIntoOsu(osu); var imported = await LoadOszIntoOsu(osu);
if (set) foreach (var b in imported.Beatmaps)
imported.OnlineBeatmapSetID = 1234; b.OnlineBeatmapID = null;
else
imported.Beatmaps.First().OnlineBeatmapID = 1234;
osu.Dependencies.Get<BeatmapManager>().Update(imported); osu.Dependencies.Get<BeatmapManager>().Update(imported);
@ -895,7 +944,17 @@ namespace osu.Game.Tests.Beatmaps.IO
Assert.IsTrue(manager.QueryBeatmapSets(_ => true).First().DeletePending); Assert.IsTrue(manager.QueryBeatmapSets(_ => true).First().DeletePending);
} }
private void checkBeatmapSetCount(OsuGameBase osu, int expected, bool includeDeletePending = false) private static Task createScoreForBeatmap(OsuGameBase osu, BeatmapInfo beatmap)
{
return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo
{
OnlineScoreID = 2,
Beatmap = beatmap,
BeatmapInfoID = beatmap.ID
}, new ImportScoreTest.TestArchiveReader());
}
private static void checkBeatmapSetCount(OsuGameBase osu, int expected, bool includeDeletePending = false)
{ {
var manager = osu.Dependencies.Get<BeatmapManager>(); var manager = osu.Dependencies.Get<BeatmapManager>();
@ -904,12 +963,18 @@ namespace osu.Game.Tests.Beatmaps.IO
: manager.GetAllUsableBeatmapSets().Count); : manager.GetAllUsableBeatmapSets().Count);
} }
private void checkBeatmapCount(OsuGameBase osu, int expected) private static string hashFile(string filename)
{
using (var s = File.OpenRead(filename))
return s.ComputeMD5Hash();
}
private static void checkBeatmapCount(OsuGameBase osu, int expected)
{ {
Assert.AreEqual(expected, osu.Dependencies.Get<BeatmapManager>().QueryBeatmaps(_ => true).ToList().Count); Assert.AreEqual(expected, osu.Dependencies.Get<BeatmapManager>().QueryBeatmaps(_ => true).ToList().Count);
} }
private void checkSingleReferencedFileCount(OsuGameBase osu, int expected) private static void checkSingleReferencedFileCount(OsuGameBase osu, int expected)
{ {
Assert.AreEqual(expected, osu.Dependencies.Get<FileStore>().QueryFiles(f => f.ReferenceCount == 1).Count()); Assert.AreEqual(expected, osu.Dependencies.Get<FileStore>().QueryFiles(f => f.ReferenceCount == 1).Count());
} }

View File

@ -0,0 +1,241 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckFewHitsoundsTest
{
private CheckFewHitsounds check;
private List<HitSampleInfo> notHitsounded;
private List<HitSampleInfo> hitsounded;
[SetUp]
public void Setup()
{
check = new CheckFewHitsounds();
notHitsounded = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
hitsounded = new List<HitSampleInfo>
{
new HitSampleInfo(HitSampleInfo.HIT_NORMAL),
new HitSampleInfo(HitSampleInfo.HIT_FINISH)
};
}
[Test]
public void TestHitsounded()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i < 16; ++i)
{
var samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
if ((i + 1) % 2 == 0)
samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP));
if ((i + 1) % 3 == 0)
samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE));
if ((i + 1) % 4 == 0)
samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH));
hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
}
assertOk(hitObjects);
}
[Test]
public void TestHitsoundedWithBreak()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i < 32; ++i)
{
var samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) };
if ((i + 1) % 2 == 0)
samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP));
if ((i + 1) % 3 == 0)
samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE));
if ((i + 1) % 4 == 0)
samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH));
// Leaves a gap in which no hitsounds exist or can be added, and so shouldn't be an issue.
if (i > 8 && i < 24)
continue;
hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
}
assertOk(hitObjects);
}
[Test]
public void TestLightlyHitsounded()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i < 30; ++i)
{
var samples = i % 8 == 0 ? hitsounded : notHitsounded;
hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
}
assertLongPeriodNegligible(hitObjects, count: 3);
}
[Test]
public void TestRarelyHitsounded()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i < 30; ++i)
{
var samples = (i == 0 || i == 15) ? hitsounded : notHitsounded;
hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
}
// Should prompt one warning between 1st and 16th, and another between 16th and 31st.
assertLongPeriodWarning(hitObjects, count: 2);
}
[Test]
public void TestExtremelyRarelyHitsounded()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i < 80; ++i)
{
var samples = i == 40 ? hitsounded : notHitsounded;
hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples });
}
// Should prompt one problem between 1st and 41st, and another between 41st and 81st.
assertLongPeriodProblem(hitObjects, count: 2);
}
[Test]
public void TestNotHitsounded()
{
var hitObjects = new List<HitObject>();
for (int i = 0; i < 20; ++i)
hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = notHitsounded });
assertNoHitsounds(hitObjects);
}
[Test]
public void TestNestedObjectsHitsounded()
{
var ticks = new List<HitObject>();
for (int i = 1; i < 16; ++i)
ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = hitsounded });
var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000)
{
Samples = hitsounded
};
nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
assertOk(new List<HitObject> { nested });
}
[Test]
public void TestNestedObjectsRarelyHitsounded()
{
var ticks = new List<HitObject>();
for (int i = 1; i < 16; ++i)
ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = i == 0 ? hitsounded : notHitsounded });
var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000)
{
Samples = hitsounded
};
nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
assertLongPeriodWarning(new List<HitObject> { nested });
}
[Test]
public void TestConcurrentObjects()
{
var hitObjects = new List<HitObject>();
var ticks = new List<HitObject>();
for (int i = 1; i < 10; ++i)
ticks.Add(new SliderTick { StartTime = 5000 * i, Samples = hitsounded });
var nested = new MockNestableHitObject(ticks.ToList(), 0, 50000)
{
Samples = notHitsounded
};
nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
hitObjects.Add(nested);
for (int i = 1; i <= 6; ++i)
hitObjects.Add(new HitCircle { StartTime = 10000 * i, Samples = notHitsounded });
assertOk(hitObjects);
}
private void assertOk(List<HitObject> hitObjects)
{
Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
}
private void assertLongPeriodProblem(List<HitObject> hitObjects, int count = 1)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodProblem));
}
private void assertLongPeriodWarning(List<HitObject> hitObjects, int count = 1)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodWarning));
}
private void assertLongPeriodNegligible(List<HitObject> hitObjects, int count = 1)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodNegligible));
}
private void assertNoHitsounds(List<HitObject> hitObjects)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Any(issue => issue.Template is CheckFewHitsounds.IssueTemplateNoHitsounds));
}
private BeatmapVerifierContext getContext(List<HitObject> hitObjects)
{
var beatmap = new Beatmap<HitObject> { HitObjects = hitObjects };
return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
}
}
}

View File

@ -0,0 +1,289 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckMutedObjectsTest
{
private CheckMutedObjects check;
private ControlPointInfo cpi;
private const int volume_regular = 50;
private const int volume_low = 15;
private const int volume_muted = 5;
[SetUp]
public void Setup()
{
check = new CheckMutedObjects();
cpi = new ControlPointInfo();
cpi.Add(0, new SampleControlPoint { SampleVolume = volume_regular });
cpi.Add(1000, new SampleControlPoint { SampleVolume = volume_low });
cpi.Add(2000, new SampleControlPoint { SampleVolume = volume_muted });
}
[Test]
public void TestNormalControlPointVolume()
{
var hitcircle = new HitCircle
{
StartTime = 0,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
assertOk(new List<HitObject> { hitcircle });
}
[Test]
public void TestLowControlPointVolume()
{
var hitcircle = new HitCircle
{
StartTime = 1000,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
assertLowVolume(new List<HitObject> { hitcircle });
}
[Test]
public void TestMutedControlPointVolume()
{
var hitcircle = new HitCircle
{
StartTime = 2000,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
assertMuted(new List<HitObject> { hitcircle });
}
[Test]
public void TestNormalSampleVolume()
{
// The sample volume should take precedence over the control point volume.
var hitcircle = new HitCircle
{
StartTime = 2000,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) }
};
hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
assertOk(new List<HitObject> { hitcircle });
}
[Test]
public void TestLowSampleVolume()
{
var hitcircle = new HitCircle
{
StartTime = 2000,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_low) }
};
hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
assertLowVolume(new List<HitObject> { hitcircle });
}
[Test]
public void TestMutedSampleVolume()
{
var hitcircle = new HitCircle
{
StartTime = 0,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) }
};
hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty());
assertMuted(new List<HitObject> { hitcircle });
}
[Test]
public void TestNormalSampleVolumeSlider()
{
var sliderHead = new SliderHeadCircle
{
StartTime = 0,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
var sliderTick = new SliderTick
{
StartTime = 250,
Samples = new List<HitSampleInfo> { new HitSampleInfo("slidertick", volume: volume_muted) } // Should be fine.
};
sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
var slider = new MockNestableHitObject(new List<HitObject> { sliderHead, sliderTick, }, startTime: 0, endTime: 500)
{
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
slider.ApplyDefaults(cpi, new BeatmapDifficulty());
assertOk(new List<HitObject> { slider });
}
[Test]
public void TestMutedSampleVolumeSliderHead()
{
var sliderHead = new SliderHeadCircle
{
StartTime = 0,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) }
};
sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
var sliderTick = new SliderTick
{
StartTime = 250,
Samples = new List<HitSampleInfo> { new HitSampleInfo("slidertick") }
};
sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
var slider = new MockNestableHitObject(new List<HitObject> { sliderHead, sliderTick, }, startTime: 0, endTime: 500)
{
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } // Applies to the tail.
};
slider.ApplyDefaults(cpi, new BeatmapDifficulty());
assertMuted(new List<HitObject> { slider });
}
[Test]
public void TestMutedSampleVolumeSliderTail()
{
var sliderHead = new SliderHeadCircle
{
StartTime = 0,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
var sliderTick = new SliderTick
{
StartTime = 250,
Samples = new List<HitSampleInfo> { new HitSampleInfo("slidertick") }
};
sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
var slider = new MockNestableHitObject(new List<HitObject> { sliderHead, sliderTick, }, startTime: 0, endTime: 2500)
{
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } // Applies to the tail.
};
slider.ApplyDefaults(cpi, new BeatmapDifficulty());
assertMutedPassive(new List<HitObject> { slider });
}
[Test]
public void TestMutedControlPointVolumeSliderHead()
{
var sliderHead = new SliderHeadCircle
{
StartTime = 2000,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
var sliderTick = new SliderTick
{
StartTime = 2250,
Samples = new List<HitSampleInfo> { new HitSampleInfo("slidertick") }
};
sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
var slider = new MockNestableHitObject(new List<HitObject> { sliderHead, sliderTick, }, startTime: 2000, endTime: 2500)
{
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) }
};
slider.ApplyDefaults(cpi, new BeatmapDifficulty());
assertMuted(new List<HitObject> { slider });
}
[Test]
public void TestMutedControlPointVolumeSliderTail()
{
var sliderHead = new SliderHeadCircle
{
StartTime = 0,
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty());
var sliderTick = new SliderTick
{
StartTime = 250,
Samples = new List<HitSampleInfo> { new HitSampleInfo("slidertick") }
};
sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty());
// Ends after the 5% control point.
var slider = new MockNestableHitObject(new List<HitObject> { sliderHead, sliderTick, }, startTime: 0, endTime: 2500)
{
Samples = new List<HitSampleInfo> { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }
};
slider.ApplyDefaults(cpi, new BeatmapDifficulty());
assertMutedPassive(new List<HitObject> { slider });
}
private void assertOk(List<HitObject> hitObjects)
{
Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
}
private void assertLowVolume(List<HitObject> hitObjects, int count = 1)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateLowVolumeActive));
}
private void assertMuted(List<HitObject> hitObjects, int count = 1)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedActive));
}
private void assertMutedPassive(List<HitObject> hitObjects)
{
var issues = check.Run(getContext(hitObjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Any(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedPassive));
}
private BeatmapVerifierContext getContext(List<HitObject> hitObjects)
{
var beatmap = new Beatmap<HitObject>
{
ControlPointInfo = cpi,
HitObjects = hitObjects
};
return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
}
}
}

View File

@ -0,0 +1,36 @@
// 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.Threading;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Tests.Editing.Checks
{
public sealed class MockNestableHitObject : HitObject, IHasDuration
{
private readonly IEnumerable<HitObject> toBeNested;
public MockNestableHitObject(IEnumerable<HitObject> toBeNested, double startTime, double endTime)
{
this.toBeNested = toBeNested;
StartTime = startTime;
EndTime = endTime;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
foreach (var hitObject in toBeNested)
AddNested(hitObject);
}
public double EndTime { get; }
public double Duration
{
get => EndTime - StartTime;
set => throw new System.NotImplementedException();
}
}
}

View File

@ -90,6 +90,20 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.Less(filterCriteria.DrainRate.Min, 6.1f); Assert.Less(filterCriteria.DrainRate.Min, 6.1f);
} }
[Test]
public void TestApplyOverallDifficultyQueries()
{
const string query = "od>4 easy od<8";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("easy", filterCriteria.SearchText.Trim());
Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
Assert.Greater(filterCriteria.OverallDifficulty.Min, 4.0);
Assert.Less(filterCriteria.OverallDifficulty.Min, 4.1);
Assert.Greater(filterCriteria.OverallDifficulty.Max, 7.9);
Assert.Less(filterCriteria.OverallDifficulty.Max, 8.0);
}
[Test] [Test]
public void TestApplyBPMQueries() public void TestApplyBPMQueries()
{ {

View File

@ -0,0 +1,95 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Textures;
using osu.Game.Audio;
using osu.Game.Rulesets;
using osu.Game.Skinning;
using osu.Game.Tests.Testing;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.Rulesets
{
public class TestSceneRulesetSkinProvidingContainer : OsuTestScene
{
private SkinRequester requester;
protected override Ruleset CreateRuleset() => new TestSceneRulesetDependencies.TestRuleset();
[Cached(typeof(ISkinSource))]
private readonly ISkinSource testSource = new TestSkinProvider();
[Test]
public void TestEarlyAddedSkinRequester()
{
Texture textureOnLoad = null;
AddStep("setup provider", () =>
{
var rulesetSkinProvider = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin);
rulesetSkinProvider.Add(requester = new SkinRequester());
requester.OnLoadAsync += () => textureOnLoad = requester.GetTexture(TestSkinProvider.TEXTURE_NAME);
Child = rulesetSkinProvider;
});
AddAssert("requester got correct initial texture", () => textureOnLoad != null);
}
private class SkinRequester : Drawable, ISkin
{
private ISkinSource skin;
public event Action OnLoadAsync;
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
this.skin = skin;
OnLoadAsync?.Invoke();
}
public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component);
public Texture GetTexture(string componentName, WrapMode wrapModeS = default, WrapMode wrapModeT = default) => skin.GetTexture(componentName);
public ISample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo);
public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => skin.GetConfig<TLookup, TValue>(lookup);
}
private class TestSkinProvider : ISkinSource
{
public const string TEXTURE_NAME = "some-texture";
public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException();
public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => componentName == TEXTURE_NAME ? Texture.WhitePixel : null;
public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException();
public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => throw new NotImplementedException();
public event Action SourceChanged
{
add { }
remove { }
}
public ISkin FindProvider(Func<ISkin, bool> lookupFunction) => lookupFunction(this) ? this : null;
public IEnumerable<ISkin> AllSources => new[] { this };
}
}
}

View File

@ -43,7 +43,7 @@ namespace osu.Game.Tests.Scores.IO
OnlineScoreID = 12345, OnlineScoreID = 12345,
}; };
var imported = await loadScoreIntoOsu(osu, toImport); var imported = await LoadScoreIntoOsu(osu, toImport);
Assert.AreEqual(toImport.Rank, imported.Rank); Assert.AreEqual(toImport.Rank, imported.Rank);
Assert.AreEqual(toImport.TotalScore, imported.TotalScore); Assert.AreEqual(toImport.TotalScore, imported.TotalScore);
@ -75,7 +75,7 @@ namespace osu.Game.Tests.Scores.IO
Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() },
}; };
var imported = await loadScoreIntoOsu(osu, toImport); var imported = await LoadScoreIntoOsu(osu, toImport);
Assert.IsTrue(imported.Mods.Any(m => m is OsuModHardRock)); Assert.IsTrue(imported.Mods.Any(m => m is OsuModHardRock));
Assert.IsTrue(imported.Mods.Any(m => m is OsuModDoubleTime)); Assert.IsTrue(imported.Mods.Any(m => m is OsuModDoubleTime));
@ -105,7 +105,7 @@ namespace osu.Game.Tests.Scores.IO
} }
}; };
var imported = await loadScoreIntoOsu(osu, toImport); var imported = await LoadScoreIntoOsu(osu, toImport);
Assert.AreEqual(toImport.Statistics[HitResult.Perfect], imported.Statistics[HitResult.Perfect]); Assert.AreEqual(toImport.Statistics[HitResult.Perfect], imported.Statistics[HitResult.Perfect]);
Assert.AreEqual(toImport.Statistics[HitResult.Miss], imported.Statistics[HitResult.Miss]); Assert.AreEqual(toImport.Statistics[HitResult.Miss], imported.Statistics[HitResult.Miss]);
@ -136,7 +136,7 @@ namespace osu.Game.Tests.Scores.IO
} }
}; };
var imported = await loadScoreIntoOsu(osu, toImport); var imported = await LoadScoreIntoOsu(osu, toImport);
var beatmapManager = osu.Dependencies.Get<BeatmapManager>(); var beatmapManager = osu.Dependencies.Get<BeatmapManager>();
var scoreManager = osu.Dependencies.Get<ScoreManager>(); var scoreManager = osu.Dependencies.Get<ScoreManager>();
@ -144,7 +144,7 @@ namespace osu.Game.Tests.Scores.IO
beatmapManager.Delete(beatmapManager.QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == imported.Beatmap.ID))); beatmapManager.Delete(beatmapManager.QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == imported.Beatmap.ID)));
Assert.That(scoreManager.Query(s => s.ID == imported.ID).DeletePending, Is.EqualTo(true)); Assert.That(scoreManager.Query(s => s.ID == imported.ID).DeletePending, Is.EqualTo(true));
var secondImport = await loadScoreIntoOsu(osu, imported); var secondImport = await LoadScoreIntoOsu(osu, imported);
Assert.That(secondImport, Is.Null); Assert.That(secondImport, Is.Null);
} }
finally finally
@ -163,7 +163,7 @@ namespace osu.Game.Tests.Scores.IO
{ {
var osu = LoadOsuIntoHost(host, true); var osu = LoadOsuIntoHost(host, true);
await loadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader()); await LoadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader());
var scoreManager = osu.Dependencies.Get<ScoreManager>(); var scoreManager = osu.Dependencies.Get<ScoreManager>();
@ -177,7 +177,7 @@ namespace osu.Game.Tests.Scores.IO
} }
} }
private async Task<ScoreInfo> loadScoreIntoOsu(OsuGameBase osu, ScoreInfo score, ArchiveReader archive = null) public static async Task<ScoreInfo> LoadScoreIntoOsu(OsuGameBase osu, ScoreInfo score, ArchiveReader archive = null)
{ {
var beatmapManager = osu.Dependencies.Get<BeatmapManager>(); var beatmapManager = osu.Dependencies.Get<BeatmapManager>();
@ -190,7 +190,7 @@ namespace osu.Game.Tests.Scores.IO
return scoreManager.GetAllUsableScores().FirstOrDefault(); return scoreManager.GetAllUsableScores().FirstOrDefault();
} }
private class TestArchiveReader : ArchiveReader internal class TestArchiveReader : ArchiveReader
{ {
public TestArchiveReader() public TestArchiveReader()
: base("test_archive") : base("test_archive")

View File

@ -52,7 +52,7 @@ namespace osu.Game.Tests.Testing
Dependencies.Get<TestRulesetConfigManager>() != null); Dependencies.Get<TestRulesetConfigManager>() != null);
} }
private class TestRuleset : Ruleset public class TestRuleset : Ruleset
{ {
public override string Description => string.Empty; public override string Description => string.Empty;
public override string ShortName => string.Empty; public override string ShortName => string.Empty;

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
@ -14,6 +15,8 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Online namespace osu.Game.Tests.Visual.Online
{ {
@ -23,6 +26,8 @@ namespace osu.Game.Tests.Visual.Online
private BeatmapListingOverlay overlay; private BeatmapListingOverlay overlay;
private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType<BeatmapListingSearchControl>().Single();
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
@ -39,6 +44,16 @@ namespace osu.Game.Tests.Visual.Online
return true; return true;
}; };
AddStep("initialize dummy", () =>
{
// non-supporter user
((DummyAPIAccess)API).LocalUser.Value = new User
{
Username = "TestBot",
Id = API.LocalUser.Value.Id + 1,
};
});
} }
[Test] [Test]
@ -58,13 +73,164 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true); AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
} }
[Test]
public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults()
{
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false);
// only Rank Achieved filter
setRankAchievedFilter(new[] { ScoreRank.XH });
supporterRequiredPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
notFoundPlaceholderShown();
// only Played filter
setPlayedFilter(SearchPlayed.Played);
supporterRequiredPlaceholderShown();
setPlayedFilter(SearchPlayed.Any);
notFoundPlaceholderShown();
// both RankAchieved and Played filters
setRankAchievedFilter(new[] { ScoreRank.XH });
setPlayedFilter(SearchPlayed.Played);
supporterRequiredPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
setPlayedFilter(SearchPlayed.Any);
notFoundPlaceholderShown();
}
[Test]
public void TestUserWithSupporterUsesSupporterOnlyFiltersWithoutResults()
{
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true);
// only Rank Achieved filter
setRankAchievedFilter(new[] { ScoreRank.XH });
notFoundPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
notFoundPlaceholderShown();
// only Played filter
setPlayedFilter(SearchPlayed.Played);
notFoundPlaceholderShown();
setPlayedFilter(SearchPlayed.Any);
notFoundPlaceholderShown();
// both Rank Achieved and Played filters
setRankAchievedFilter(new[] { ScoreRank.XH });
setPlayedFilter(SearchPlayed.Played);
notFoundPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
setPlayedFilter(SearchPlayed.Any);
notFoundPlaceholderShown();
}
[Test]
public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithResults()
{
AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet));
AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false);
// only Rank Achieved filter
setRankAchievedFilter(new[] { ScoreRank.XH });
supporterRequiredPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
noPlaceholderShown();
// only Played filter
setPlayedFilter(SearchPlayed.Played);
supporterRequiredPlaceholderShown();
setPlayedFilter(SearchPlayed.Any);
noPlaceholderShown();
// both Rank Achieved and Played filters
setRankAchievedFilter(new[] { ScoreRank.XH });
setPlayedFilter(SearchPlayed.Played);
supporterRequiredPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
setPlayedFilter(SearchPlayed.Any);
noPlaceholderShown();
}
[Test]
public void TestUserWithSupporterUsesSupporterOnlyFiltersWithResults()
{
AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet));
AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true);
// only Rank Achieved filter
setRankAchievedFilter(new[] { ScoreRank.XH });
noPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
noPlaceholderShown();
// only Played filter
setPlayedFilter(SearchPlayed.Played);
noPlaceholderShown();
setPlayedFilter(SearchPlayed.Any);
noPlaceholderShown();
// both Rank Achieved and Played filters
setRankAchievedFilter(new[] { ScoreRank.XH });
setPlayedFilter(SearchPlayed.Played);
noPlaceholderShown();
setRankAchievedFilter(Array.Empty<ScoreRank>());
setPlayedFilter(SearchPlayed.Any);
noPlaceholderShown();
}
private void fetchFor(params BeatmapSetInfo[] beatmaps) private void fetchFor(params BeatmapSetInfo[] beatmaps)
{ {
setsForResponse.Clear(); setsForResponse.Clear();
setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b))); setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b)));
// trigger arbitrary change for fetching. // trigger arbitrary change for fetching.
overlay.ChildrenOfType<BeatmapListingSearchControl>().Single().Query.TriggerChange(); searchControl.Query.TriggerChange();
}
private void setRankAchievedFilter(ScoreRank[] ranks)
{
AddStep($"set Rank Achieved filter to [{string.Join(',', ranks)}]", () =>
{
searchControl.Ranks.Clear();
searchControl.Ranks.AddRange(ranks);
});
}
private void setPlayedFilter(SearchPlayed played)
{
AddStep($"set Played filter to {played}", () => searchControl.Played.Value = played);
}
private void supporterRequiredPlaceholderShown()
{
AddUntilStep("\"supporter required\" placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.SupporterRequiredDrawable>().SingleOrDefault()?.IsPresent == true);
}
private void notFoundPlaceholderShown()
{
AddUntilStep("\"no maps found\" placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
}
private void noPlaceholderShown()
{
AddUntilStep("no placeholder shown", () =>
!overlay.ChildrenOfType<BeatmapListingOverlay.SupporterRequiredDrawable>().Any()
&& !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any());
} }
private class TestAPIBeatmapSet : APIBeatmapSet private class TestAPIBeatmapSet : APIBeatmapSet

View File

@ -37,8 +37,6 @@ namespace osu.Game.Tests.Visual.Playlists
Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default));
manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait();
((DummyAPIAccess)API).HandleRequest = req => ((DummyAPIAccess)API).HandleRequest = req =>
{ {
switch (req) switch (req)
@ -56,6 +54,7 @@ namespace osu.Game.Tests.Visual.Playlists
public void SetupSteps() public void SetupSteps()
{ {
AddStep("set room", () => SelectedRoom.Value = new Room()); AddStep("set room", () => SelectedRoom.Value = new Room());
AddStep("ensure has beatmap", () => manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait());
AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value))); AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value)));
AddUntilStep("wait for load", () => match.IsCurrentScreen()); AddUntilStep("wait for load", () => match.IsCurrentScreen());
} }
@ -109,10 +108,27 @@ namespace osu.Game.Tests.Visual.Playlists
public void TestBeatmapUpdatedOnReImport() public void TestBeatmapUpdatedOnReImport()
{ {
BeatmapSetInfo importedSet = null; BeatmapSetInfo importedSet = null;
TestBeatmap beatmap = null;
// this step is required to make sure the further imports actually get online IDs.
// all the playlist logic relies on online ID matching.
AddStep("remove all matching online IDs", () =>
{
beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo);
var existing = manager.QueryBeatmapSets(s => s.OnlineBeatmapSetID == beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID).ToList();
foreach (var s in existing)
{
s.OnlineBeatmapSetID = null;
foreach (var b in s.Beatmaps)
b.OnlineBeatmapID = null;
manager.Update(s);
}
});
AddStep("import altered beatmap", () => AddStep("import altered beatmap", () =>
{ {
var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo);
beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1; beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1;
importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result; importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result;

View File

@ -10,7 +10,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Mods; using osu.Game.Overlays.Mods;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -107,7 +106,6 @@ namespace osu.Game.Tests.Visual.UserInterface
var conversionMods = osu.GetModsFor(ModType.Conversion); var conversionMods = osu.GetModsFor(ModType.Conversion);
var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail);
var hiddenMod = harderMods.FirstOrDefault(m => m is OsuModHidden);
var doubleTimeMod = harderMods.OfType<MultiMod>().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime)); var doubleTimeMod = harderMods.OfType<MultiMod>().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime));
@ -120,8 +118,6 @@ namespace osu.Game.Tests.Visual.UserInterface
testMultiMod(doubleTimeMod); testMultiMod(doubleTimeMod);
testIncompatibleMods(easy, hardRock); testIncompatibleMods(easy, hardRock);
testDeselectAll(easierMods.Where(m => !(m is MultiMod))); testDeselectAll(easierMods.Where(m => !(m is MultiMod)));
testMultiplierTextColour(noFailMod, () => modSelect.LowMultiplierColour);
testMultiplierTextColour(hiddenMod, () => modSelect.HighMultiplierColour);
testUnimplementedMod(targetMod); testUnimplementedMod(targetMod);
} }
@ -149,7 +145,7 @@ namespace osu.Game.Tests.Visual.UserInterface
changeRuleset(0); changeRuleset(0);
AddAssert("ensure mods still selected", () => modDisplay.Current.Value.Single(m => m is OsuModNoFail) != null); AddAssert("ensure mods still selected", () => modDisplay.Current.Value.SingleOrDefault(m => m is OsuModNoFail) != null);
changeRuleset(3); changeRuleset(3);
@ -316,17 +312,6 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any()); AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any());
} }
private void testMultiplierTextColour(Mod mod, Func<Color4> getCorrectColour)
{
checkLabelColor(() => Color4.White);
selectNext(mod);
AddWaitStep("wait for changing colour", 1);
checkLabelColor(getCorrectColour);
selectPrevious(mod);
AddWaitStep("wait for changing colour", 1);
checkLabelColor(() => Color4.White);
}
private void testModsWithSameBaseType(Mod modA, Mod modB) private void testModsWithSameBaseType(Mod modA, Mod modB)
{ {
selectNext(modA); selectNext(modA);
@ -348,7 +333,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert($"check {mod.Name} is selected", () => AddAssert($"check {mod.Name} is selected", () =>
{ {
var button = modSelect.GetModButton(mod); var button = modSelect.GetModButton(mod);
return modSelect.SelectedMods.Value.Single(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected; return modSelect.SelectedMods.Value.SingleOrDefault(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected;
}); });
} }
@ -370,8 +355,6 @@ namespace osu.Game.Tests.Visual.UserInterface
}); });
} }
private void checkLabelColor(Func<Color4> getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour());
private void createDisplay(Func<TestModSelectOverlay> createOverlayFunc) private void createDisplay(Func<TestModSelectOverlay> createOverlayFunc)
{ {
Children = new Drawable[] Children = new Drawable[]
@ -408,7 +391,6 @@ namespace osu.Game.Tests.Visual.UserInterface
return section.ButtonsContainer.OfType<ModButton>().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); return section.ButtonsContainer.OfType<ModButton>().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType()));
} }
public new OsuSpriteText MultiplierLabel => base.MultiplierLabel;
public new TriangleButton DeselectAllButton => base.DeselectAllButton; public new TriangleButton DeselectAllButton => base.DeselectAllButton;
public new Color4 LowMultiplierColour => base.LowMultiplierColour; public new Color4 LowMultiplierColour => base.LowMultiplierColour;

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osuTK; using osuTK;
@ -59,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface
private class Icon : Container, IHasTooltip private class Icon : Container, IHasTooltip
{ {
public string TooltipText { get; } public LocalisableString TooltipText { get; }
public SpriteIcon SpriteIcon { get; } public SpriteIcon SpriteIcon { get; }

View File

@ -147,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Editors
[Resolved] [Resolved]
protected IAPIProvider API { get; private set; } protected IAPIProvider API { get; private set; }
private readonly Bindable<string> beatmapId = new Bindable<string>(); private readonly Bindable<int?> beatmapId = new Bindable<int?>();
private readonly Bindable<string> mods = new Bindable<string>(); private readonly Bindable<string> mods = new Bindable<string>();
@ -220,14 +220,12 @@ namespace osu.Game.Tournament.Screens.Editors
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(RulesetStore rulesets) private void load(RulesetStore rulesets)
{ {
beatmapId.Value = Model.ID.ToString(); beatmapId.Value = Model.ID;
beatmapId.BindValueChanged(idString => beatmapId.BindValueChanged(id =>
{ {
int.TryParse(idString.NewValue, out var parsed); Model.ID = id.NewValue ?? 0;
Model.ID = parsed; if (id.NewValue != id.OldValue)
if (idString.NewValue != idString.OldValue)
Model.BeatmapInfo = null; Model.BeatmapInfo = null;
if (Model.BeatmapInfo != null) if (Model.BeatmapInfo != null)

View File

@ -147,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Editors
[Resolved] [Resolved]
protected IAPIProvider API { get; private set; } protected IAPIProvider API { get; private set; }
private readonly Bindable<string> beatmapId = new Bindable<string>(); private readonly Bindable<int?> beatmapId = new Bindable<int?>();
private readonly Bindable<string> score = new Bindable<string>(); private readonly Bindable<string> score = new Bindable<string>();
@ -228,16 +228,12 @@ namespace osu.Game.Tournament.Screens.Editors
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(RulesetStore rulesets) private void load(RulesetStore rulesets)
{ {
beatmapId.Value = Model.ID.ToString(); beatmapId.Value = Model.ID;
beatmapId.BindValueChanged(idString => beatmapId.BindValueChanged(id =>
{ {
int parsed; Model.ID = id.NewValue ?? 0;
int.TryParse(idString.NewValue, out parsed); if (id.NewValue != id.OldValue)
Model.ID = parsed;
if (idString.NewValue != idString.OldValue)
Model.BeatmapInfo = null; Model.BeatmapInfo = null;
if (Model.BeatmapInfo != null) if (Model.BeatmapInfo != null)

View File

@ -214,7 +214,7 @@ namespace osu.Game.Tournament.Screens.Editors
[Resolved] [Resolved]
private TournamentGameBase game { get; set; } private TournamentGameBase game { get; set; }
private readonly Bindable<string> userId = new Bindable<string>(); private readonly Bindable<int?> userId = new Bindable<int?>();
private readonly Container drawableContainer; private readonly Container drawableContainer;
@ -278,14 +278,12 @@ namespace osu.Game.Tournament.Screens.Editors
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
userId.Value = user.Id.ToString(); userId.Value = user.Id;
userId.BindValueChanged(idString => userId.BindValueChanged(id =>
{ {
int.TryParse(idString.NewValue, out var parsed); user.Id = id.NewValue ?? 0;
user.Id = parsed; if (id.NewValue != id.OldValue)
if (idString.NewValue != idString.OldValue)
user.Username = string.Empty; user.Username = string.Empty;
if (!string.IsNullOrEmpty(user.Username)) if (!string.IsNullOrEmpty(user.Username))

View File

@ -181,8 +181,13 @@ namespace osu.Game.Beatmaps
if (existingOnlineId != null) if (existingOnlineId != null)
{ {
Delete(existingOnlineId); Delete(existingOnlineId);
beatmaps.PurgeDeletable(s => s.ID == existingOnlineId.ID);
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been purged."); // in order to avoid a unique key constraint, immediately remove the online ID from the previous set.
existingOnlineId.OnlineBeatmapSetID = null;
foreach (var b in existingOnlineId.Beatmaps)
b.OnlineBeatmapID = null;
LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted.");
} }
} }
} }
@ -191,8 +196,6 @@ namespace osu.Game.Beatmaps
{ {
var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList(); var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList();
LogForModel(beatmapSet, $"Validating online IDs for {beatmapSet.Beatmaps.Count} beatmaps...");
// ensure all IDs are unique // ensure all IDs are unique
if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1))
{ {
@ -319,6 +322,14 @@ namespace osu.Game.Beatmaps
/// <returns>The first result for the provided query, or null if no results were found.</returns> /// <returns>The first result for the provided query, or null if no results were found.</returns>
public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query); public BeatmapSetInfo QueryBeatmapSet(Expression<Func<BeatmapSetInfo, bool>> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query);
protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import)
{
if (!base.CanReuseExisting(existing, import))
return false;
return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null);
}
protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import) protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import)
{ {
if (!base.CanReuseExisting(existing, import)) if (!base.CanReuseExisting(existing, import))

View File

@ -48,7 +48,6 @@ namespace osu.Game.Beatmaps
public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken)
{ {
LogForModel(beatmapSet, "Performing online lookups...");
return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray());
} }

View File

@ -12,6 +12,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
namespace osu.Game.Configuration namespace osu.Game.Configuration
@ -30,7 +31,7 @@ namespace osu.Game.Configuration
{ {
public LocalisableString Label { get; } public LocalisableString Label { get; }
public string Description { get; } public LocalisableString Description { get; }
public int? OrderPosition { get; } public int? OrderPosition { get; }
@ -149,7 +150,7 @@ namespace osu.Game.Configuration
break; break;
case IBindable bindable: case IBindable bindable:
var dropdownType = typeof(SettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]);
var dropdown = (Drawable)Activator.CreateInstance(dropdownType); var dropdown = (Drawable)Activator.CreateInstance(dropdownType);
dropdownType.GetProperty(nameof(SettingsDropdown<object>.LabelText))?.SetValue(dropdown, attr.Label); dropdownType.GetProperty(nameof(SettingsDropdown<object>.LabelText))?.SetValue(dropdown, attr.Label);
@ -183,5 +184,17 @@ namespace osu.Game.Configuration
=> obj.GetSettingsSourceProperties() => obj.GetSettingsSourceProperties()
.OrderBy(attr => attr.Item1) .OrderBy(attr => attr.Item1)
.ToArray(); .ToArray();
private class ModSettingsEnumDropdown<T> : SettingsEnumDropdown<T>
where T : struct, Enum
{
protected override OsuDropdown<T> CreateDropdown() => new ModDropdownControl();
private class ModDropdownControl : DropdownControl
{
// Set menu's max height low enough to workaround nested scroll issues (see https://github.com/ppy/osu-framework/issues/4536).
protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 100);
}
}
} }
} }

View File

@ -78,7 +78,7 @@ namespace osu.Game.Database
private readonly Bindable<WeakReference<TModel>> itemRemoved = new Bindable<WeakReference<TModel>>(); private readonly Bindable<WeakReference<TModel>> itemRemoved = new Bindable<WeakReference<TModel>>();
public virtual IEnumerable<string> HandledExtensions => new[] { ".zip" }; public virtual IEnumerable<string> HandledExtensions => new[] { @".zip" };
protected readonly FileStore Files; protected readonly FileStore Files;
@ -99,7 +99,7 @@ namespace osu.Game.Database
ModelStore.ItemUpdated += item => handleEvent(() => itemUpdated.Value = new WeakReference<TModel>(item)); ModelStore.ItemUpdated += item => handleEvent(() => itemUpdated.Value = new WeakReference<TModel>(item));
ModelStore.ItemRemoved += item => handleEvent(() => itemRemoved.Value = new WeakReference<TModel>(item)); ModelStore.ItemRemoved += item => handleEvent(() => itemRemoved.Value = new WeakReference<TModel>(item));
exportStorage = storage.GetStorageForDirectory("exports"); exportStorage = storage.GetStorageForDirectory(@"exports");
Files = new FileStore(contextFactory, storage); Files = new FileStore(contextFactory, storage);
@ -282,7 +282,7 @@ namespace osu.Game.Database
} }
catch (Exception e) catch (Exception e)
{ {
LogForModel(model, $"Model creation of {archive.Name} failed.", e); LogForModel(model, @$"Model creation of {archive.Name} failed.", e);
return null; return null;
} }
@ -309,6 +309,12 @@ namespace osu.Game.Database
Logger.Log($"{prefix} {message}", LoggingTarget.Database); Logger.Log($"{prefix} {message}", LoggingTarget.Database);
} }
/// <summary>
/// Whether the implementation overrides <see cref="ComputeHash"/> with a custom implementation.
/// Custom hash implementations must bypass the early exit in the import flow (see <see cref="computeHashFast"/> usage).
/// </summary>
protected virtual bool HasCustomHashFunction => false;
/// <summary> /// <summary>
/// Create a SHA-2 hash from the provided archive based on file content of all files matching <see cref="HashableFileTypes"/>. /// Create a SHA-2 hash from the provided archive based on file content of all files matching <see cref="HashableFileTypes"/>.
/// </summary> /// </summary>
@ -317,7 +323,11 @@ namespace osu.Game.Database
/// </remarks> /// </remarks>
protected virtual string ComputeHash(TModel item, ArchiveReader reader = null) protected virtual string ComputeHash(TModel item, ArchiveReader reader = null)
{ {
// for now, concatenate all .osu files in the set to create a unique hash. if (reader != null)
// fast hashing for cases where the item's files may not be populated.
return computeHashFast(reader);
// for now, concatenate all hashable files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream(); MemoryStream hashable = new MemoryStream();
foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename))
@ -329,9 +339,6 @@ namespace osu.Game.Database
if (hashable.Length > 0) if (hashable.Length > 0)
return hashable.ComputeSHA2Hash(); return hashable.ComputeSHA2Hash();
if (reader != null)
return reader.Name.ComputeSHA2Hash();
return item.Hash; return item.Hash;
} }
@ -348,19 +355,48 @@ namespace osu.Game.Database
delayEvents(); delayEvents();
bool checkedExisting = false;
TModel existing = null;
if (archive != null && !HasCustomHashFunction)
{
// this is a fast bail condition to improve large import performance.
item.Hash = computeHashFast(archive);
checkedExisting = true;
existing = CheckForExisting(item);
if (existing != null)
{
// bare minimum comparisons
//
// note that this should really be checking filesizes on disk (of existing files) for some degree of sanity.
// or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files.
if (CanSkipImport(existing, item) &&
getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f)))
{
LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
Undelete(existing);
return existing;
}
LogForModel(item, @"Found existing (optimised) but failed pre-check.");
}
}
void rollback() void rollback()
{ {
if (!Delete(item)) if (!Delete(item))
{ {
// We may have not yet added the model to the underlying table, but should still clean up files. // We may have not yet added the model to the underlying table, but should still clean up files.
LogForModel(item, "Dereferencing files for incomplete import."); LogForModel(item, @"Dereferencing files for incomplete import.");
Files.Dereference(item.Files.Select(f => f.FileInfo).ToArray()); Files.Dereference(item.Files.Select(f => f.FileInfo).ToArray());
} }
} }
try try
{ {
LogForModel(item, "Beginning import..."); LogForModel(item, @"Beginning import...");
item.Files = archive != null ? createFileInfos(archive, Files) : new List<TFileModel>(); item.Files = archive != null ? createFileInfos(archive, Files) : new List<TFileModel>();
item.Hash = ComputeHash(item, archive); item.Hash = ComputeHash(item, archive);
@ -371,22 +407,24 @@ namespace osu.Game.Database
{ {
try try
{ {
if (!write.IsTransactionLeader) throw new InvalidOperationException($"Ensure there is no parent transaction so errors can correctly be handled by {this}"); if (!write.IsTransactionLeader) throw new InvalidOperationException(@$"Ensure there is no parent transaction so errors can correctly be handled by {this}");
var existing = CheckForExisting(item); if (!checkedExisting)
existing = CheckForExisting(item);
if (existing != null) if (existing != null)
{ {
if (CanReuseExisting(existing, item)) if (CanReuseExisting(existing, item))
{ {
Undelete(existing); Undelete(existing);
LogForModel(item, $"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) skipping import."); LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
// existing item will be used; rollback new import and exit early. // existing item will be used; rollback new import and exit early.
rollback(); rollback();
flushEvents(true); flushEvents(true);
return existing; return existing;
} }
LogForModel(item, @"Found existing but failed re-use check.");
Delete(existing); Delete(existing);
ModelStore.PurgeDeletable(s => s.ID == existing.ID); ModelStore.PurgeDeletable(s => s.ID == existing.ID);
} }
@ -403,12 +441,12 @@ namespace osu.Game.Database
} }
} }
LogForModel(item, "Import successfully completed!"); LogForModel(item, @"Import successfully completed!");
} }
catch (Exception e) catch (Exception e)
{ {
if (!(e is TaskCanceledException)) if (!(e is TaskCanceledException))
LogForModel(item, "Database import or population failed and has been rolled back.", e); LogForModel(item, @"Database import or population failed and has been rolled back.", e);
rollback(); rollback();
flushEvents(false); flushEvents(false);
@ -428,7 +466,7 @@ namespace osu.Game.Database
var retrievedItem = ModelStore.ConsumableItems.FirstOrDefault(s => s.ID == item.ID); var retrievedItem = ModelStore.ConsumableItems.FirstOrDefault(s => s.ID == item.ID);
if (retrievedItem == null) if (retrievedItem == null)
throw new ArgumentException("Specified model could not be found", nameof(item)); throw new ArgumentException(@"Specified model could not be found", nameof(item));
using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create))
ExportModelTo(retrievedItem, outputStream); ExportModelTo(retrievedItem, outputStream);
@ -637,6 +675,22 @@ namespace osu.Game.Database
} }
} }
private string computeHashFast(ArchiveReader reader)
{
MemoryStream hashable = new MemoryStream();
foreach (var file in reader.Filenames.Where(f => HashableFileTypes.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f))
{
using (Stream s = reader.GetStream(file))
s.CopyTo(hashable);
}
if (hashable.Length > 0)
return hashable.ComputeSHA2Hash();
return reader.Name.ComputeSHA2Hash();
}
/// <summary> /// <summary>
/// Create all required <see cref="IO.FileInfo"/>s for the provided archive, adding them to the global file store. /// Create all required <see cref="IO.FileInfo"/>s for the provided archive, adding them to the global file store.
/// </summary> /// </summary>
@ -644,18 +698,14 @@ namespace osu.Game.Database
{ {
var fileInfos = new List<TFileModel>(); var fileInfos = new List<TFileModel>();
string prefix = reader.Filenames.GetCommonPrefix();
if (!(prefix.EndsWith('/') || prefix.EndsWith('\\')))
prefix = string.Empty;
// import files to manager // import files to manager
foreach (string file in reader.Filenames) foreach (var filenames in getShortenedFilenames(reader))
{ {
using (Stream s = reader.GetStream(file)) using (Stream s = reader.GetStream(filenames.original))
{ {
fileInfos.Add(new TFileModel fileInfos.Add(new TFileModel
{ {
Filename = file.Substring(prefix.Length).ToStandardisedPath(), Filename = filenames.shortened,
FileInfo = files.Add(s) FileInfo = files.Add(s)
}); });
} }
@ -664,6 +714,17 @@ namespace osu.Game.Database
return fileInfos; return fileInfos;
} }
private IEnumerable<(string original, string shortened)> getShortenedFilenames(ArchiveReader reader)
{
string prefix = reader.Filenames.GetCommonPrefix();
if (!(prefix.EndsWith('/') || prefix.EndsWith('\\')))
prefix = string.Empty;
// import files to manager
foreach (string file in reader.Filenames)
yield return (file, file.Substring(prefix.Length).ToStandardisedPath());
}
#region osu-stable import #region osu-stable import
/// <summary> /// <summary>
@ -696,7 +757,7 @@ namespace osu.Game.Database
{ {
string fullPath = storage.GetFullPath(ImportFromStablePath); string fullPath = storage.GetFullPath(ImportFromStablePath);
Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error); Logger.Log(@$"Folder ""{fullPath}"" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -727,7 +788,7 @@ namespace osu.Game.Database
/// <param name="model">The model to populate.</param> /// <param name="model">The model to populate.</param>
/// <param name="archive">The archive to use as a reference for population. May be null.</param> /// <param name="archive">The archive to use as a reference for population. May be null.</param>
/// <param name="cancellationToken">An optional cancellation token.</param> /// <param name="cancellationToken">An optional cancellation token.</param>
protected virtual Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default) => Task.CompletedTask; protected abstract Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Perform any final actions before the import to database executes. /// Perform any final actions before the import to database executes.
@ -744,6 +805,15 @@ namespace osu.Game.Database
/// <returns>An existing model which matches the criteria to skip importing, else null.</returns> /// <returns>An existing model which matches the criteria to skip importing, else null.</returns>
protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash); protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash);
/// <summary>
/// Whether inport can be skipped after finding an existing import early in the process.
/// Only valid when <see cref="ComputeHash"/> is not overridden.
/// </summary>
/// <param name="existing">The existing model.</param>
/// <param name="import">The newly imported model.</param>
/// <returns>Whether to skip this import completely.</returns>
protected virtual bool CanSkipImport(TModel existing, TModel import) => true;
/// <summary> /// <summary>
/// After an existing <typeparamref name="TModel"/> is found during an import process, the default behaviour is to use/restore the existing /// After an existing <typeparamref name="TModel"/> is found during an import process, the default behaviour is to use/restore the existing
/// item and skip the import. This method allows changing that behaviour. /// item and skip the import. This method allows changing that behaviour.
@ -771,7 +841,7 @@ namespace osu.Game.Database
private DbSet<TModel> queryModel() => ContextFactory.Get().Set<TModel>(); private DbSet<TModel> queryModel() => ContextFactory.Get().Set<TModel>();
protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace("Info", "").ToLower()}"; protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}";
#region Event handling / delaying #region Event handling / delaying

View File

@ -26,6 +26,11 @@ namespace osu.Game.Database
/// </summary> /// </summary>
private readonly object writeLock = new object(); private readonly object writeLock = new object();
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections.
/// </summary>
private readonly SemaphoreSlim blockingLock = new SemaphoreSlim(1);
private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Realm", "Get (Read)"); private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Realm", "Get (Read)");
private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Realm", "Get (Write)"); private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Realm", "Get (Write)");
private static readonly GlobalStatistic<int> refreshes = GlobalStatistics.Get<int>("Realm", "Dirty Refreshes"); private static readonly GlobalStatistic<int> refreshes = GlobalStatistics.Get<int>("Realm", "Dirty Refreshes");
@ -33,17 +38,12 @@ namespace osu.Game.Database
private static readonly GlobalStatistic<int> pending_writes = GlobalStatistics.Get<int>("Realm", "Pending writes"); private static readonly GlobalStatistic<int> pending_writes = GlobalStatistics.Get<int>("Realm", "Pending writes");
private static readonly GlobalStatistic<int> active_usages = GlobalStatistics.Get<int>("Realm", "Active usages"); private static readonly GlobalStatistic<int> active_usages = GlobalStatistics.Get<int>("Realm", "Active usages");
private readonly ManualResetEventSlim blockingResetEvent = new ManualResetEventSlim(true);
private Realm context; private Realm context;
public Realm Context public Realm Context
{ {
get get
{ {
if (IsDisposed)
throw new InvalidOperationException($"Attempted to access {nameof(Context)} on a disposed context factory");
if (context == null) if (context == null)
{ {
context = createContext(); context = createContext();
@ -64,7 +64,7 @@ namespace osu.Game.Database
public RealmUsage GetForRead() public RealmUsage GetForRead()
{ {
reads.Value++; reads.Value++;
return new RealmUsage(this); return new RealmUsage(createContext());
} }
public RealmWriteUsage GetForWrite() public RealmWriteUsage GetForWrite()
@ -73,8 +73,28 @@ namespace osu.Game.Database
pending_writes.Value++; pending_writes.Value++;
Monitor.Enter(writeLock); Monitor.Enter(writeLock);
return new RealmWriteUsage(createContext(), writeComplete);
}
return new RealmWriteUsage(this); /// <summary>
/// Flush any active contexts and block any further writes.
/// </summary>
/// <remarks>
/// This should be used in places we need to ensure no ongoing reads/writes are occurring with realm.
/// ie. to move the realm backing file to a new location.
/// </remarks>
/// <returns>An <see cref="IDisposable"/> which should be disposed to end the blocking section.</returns>
public IDisposable BlockAllOperations()
{
if (IsDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory));
blockingLock.Wait();
flushContexts();
return new InvokeOnDisposal<RealmContextFactory>(this, endBlockingSection);
static void endBlockingSection(RealmContextFactory factory) => factory.blockingLock.Release();
} }
protected override void Update() protected override void Update()
@ -87,7 +107,12 @@ namespace osu.Game.Database
private Realm createContext() private Realm createContext()
{ {
blockingResetEvent.Wait(); try
{
if (IsDisposed)
throw new ObjectDisposedException(nameof(RealmContextFactory));
blockingLock.Wait();
contexts_created.Value++; contexts_created.Value++;
@ -97,6 +122,17 @@ namespace osu.Game.Database
MigrationCallback = onMigration, MigrationCallback = onMigration,
}); });
} }
finally
{
blockingLock.Release();
}
}
private void writeComplete()
{
Monitor.Exit(writeLock);
pending_writes.Value--;
}
private void onMigration(Migration migration, ulong lastSchemaVersion) private void onMigration(Migration migration, ulong lastSchemaVersion)
{ {
@ -109,26 +145,6 @@ namespace osu.Game.Database
} }
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
BlockAllOperations();
}
public IDisposable BlockAllOperations()
{
blockingResetEvent.Reset();
flushContexts();
return new InvokeOnDisposal<RealmContextFactory>(this, r => endBlockingSection());
}
private void endBlockingSection()
{
blockingResetEvent.Set();
}
private void flushContexts() private void flushContexts()
{ {
var previousContext = context; var previousContext = context;
@ -141,6 +157,18 @@ namespace osu.Game.Database
previousContext?.Dispose(); previousContext?.Dispose();
} }
protected override void Dispose(bool isDisposing)
{
if (!IsDisposed)
{
// intentionally block all operations indefinitely. this ensures that nothing can start consuming a new context after disposal.
BlockAllOperations();
blockingLock?.Dispose();
}
base.Dispose(isDisposing);
}
/// <summary> /// <summary>
/// A usage of realm from an arbitrary thread. /// A usage of realm from an arbitrary thread.
/// </summary> /// </summary>
@ -148,13 +176,10 @@ namespace osu.Game.Database
{ {
public readonly Realm Realm; public readonly Realm Realm;
protected readonly RealmContextFactory Factory; internal RealmUsage(Realm context)
internal RealmUsage(RealmContextFactory factory)
{ {
active_usages.Value++; active_usages.Value++;
Factory = factory; Realm = context;
Realm = factory.createContext();
} }
/// <summary> /// <summary>
@ -172,11 +197,13 @@ namespace osu.Game.Database
/// </summary> /// </summary>
public class RealmWriteUsage : RealmUsage public class RealmWriteUsage : RealmUsage
{ {
private readonly Action onWriteComplete;
private readonly Transaction transaction; private readonly Transaction transaction;
internal RealmWriteUsage(RealmContextFactory factory) internal RealmWriteUsage(Realm context, Action onWriteComplete)
: base(factory) : base(context)
{ {
this.onWriteComplete = onWriteComplete;
transaction = Realm.BeginWrite(); transaction = Realm.BeginWrite();
} }
@ -200,8 +227,7 @@ namespace osu.Game.Database
base.Dispose(); base.Dispose();
Monitor.Exit(Factory.writeLock); onWriteComplete();
pending_writes.Value--;
} }
} }
} }

View File

@ -4,12 +4,13 @@
using Markdig.Syntax.Inlines; using Markdig.Syntax.Inlines;
using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.Containers.Markdown namespace osu.Game.Graphics.Containers.Markdown
{ {
public class OsuMarkdownImage : MarkdownImage, IHasTooltip public class OsuMarkdownImage : MarkdownImage, IHasTooltip
{ {
public string TooltipText { get; } public LocalisableString TooltipText { get; }
public OsuMarkdownImage(LinkInline linkInline) public OsuMarkdownImage(LinkInline linkInline)
: base(linkInline.Url) : base(linkInline.Url)

View File

@ -5,6 +5,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
namespace osu.Game.Graphics.Containers namespace osu.Game.Graphics.Containers
@ -24,7 +25,7 @@ namespace osu.Game.Graphics.Containers
this.sampleSet = sampleSet; this.sampleSet = sampleSet;
} }
public virtual string TooltipText { get; set; } public virtual LocalisableString TooltipText { get; set; }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()

View File

@ -9,6 +9,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
namespace osu.Game.Graphics.Cursor namespace osu.Game.Graphics.Cursor
@ -32,7 +33,7 @@ namespace osu.Game.Graphics.Cursor
public override bool SetContent(object content) public override bool SetContent(object content)
{ {
if (!(content is string contentString)) if (!(content is LocalisableString contentString))
return false; return false;
if (contentString == text.Text) return true; if (contentString == text.Text) return true;

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Platform; using osu.Framework.Platform;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -58,6 +59,6 @@ namespace osu.Game.Graphics.UserInterface
return true; return true;
} }
public string TooltipText => "view in browser"; public LocalisableString TooltipText => "view in browser";
} }
} }

View File

@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Platform; using osu.Framework.Platform;
namespace osu.Game.Graphics.UserInterface namespace osu.Game.Graphics.UserInterface
@ -105,7 +106,7 @@ namespace osu.Game.Graphics.UserInterface
private class CapsWarning : SpriteIcon, IHasTooltip private class CapsWarning : SpriteIcon, IHasTooltip
{ {
public string TooltipText => @"caps lock is active"; public LocalisableString TooltipText => "caps lock is active";
public CapsWarning() public CapsWarning()
{ {

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.UserInterface;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.UserInterface namespace osu.Game.Graphics.UserInterface
{ {
@ -34,7 +35,7 @@ namespace osu.Game.Graphics.UserInterface
private readonly Box rightBox; private readonly Box rightBox;
private readonly Container nubContainer; private readonly Container nubContainer;
public virtual string TooltipText { get; private set; } public virtual LocalisableString TooltipText { get; private set; }
/// <summary> /// <summary>
/// Whether to format the tooltip as a percentage or the actual value. /// Whether to format the tooltip as a percentage or the actual value.

View File

@ -103,8 +103,11 @@ namespace osu.Game.Localisation
[Description(@"简体中文")] [Description(@"简体中文")]
zh, zh,
[Description(@"繁體中文(香港)")] // Traditional Chinese (Hong Kong) is listed in web sources but has no associated localisations,
zh_hk, // and was wrongly falling back to Simplified Chinese.
// Can be revisited if localisations ever arrive.
// [Description(@"繁體中文(香港)")]
// zh_hk,
[Description(@"繁體中文(台灣)")] [Description(@"繁體中文(台灣)")]
zh_tw zh_tw

View File

@ -13,6 +13,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
@ -295,7 +296,7 @@ namespace osu.Game.Online.Leaderboards
public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos);
public string TooltipText { get; } public LocalisableString TooltipText { get; }
public ScoreComponentLabel(LeaderboardScoreStatistic statistic) public ScoreComponentLabel(LeaderboardScoreStatistic statistic)
{ {
@ -365,7 +366,7 @@ namespace osu.Game.Online.Leaderboards
}; };
} }
public string TooltipText { get; } public LocalisableString TooltipText { get; }
} }
public class LeaderboardScoreStatistic public class LeaderboardScoreStatistic

View File

@ -24,6 +24,7 @@ using osu.Framework.Graphics.Performance;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Threading;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Input; using osu.Game.Input;
@ -156,6 +157,8 @@ namespace osu.Game
private readonly BindableNumber<double> globalTrackVolumeAdjust = new BindableNumber<double>(GLOBAL_TRACK_VOLUME_ADJUST); private readonly BindableNumber<double> globalTrackVolumeAdjust = new BindableNumber<double>(GLOBAL_TRACK_VOLUME_ADJUST);
private IBindable<GameThreadState> updateThreadState;
public OsuGameBase() public OsuGameBase()
{ {
UseDevelopmentServer = DebugUtils.IsDebugBuild; UseDevelopmentServer = DebugUtils.IsDebugBuild;
@ -182,6 +185,10 @@ namespace osu.Game
dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage));
dependencies.Cache(realmFactory = new RealmContextFactory(Storage)); dependencies.Cache(realmFactory = new RealmContextFactory(Storage));
updateThreadState = Host.UpdateThread.State.GetBoundCopy();
updateThreadState.BindValueChanged(updateThreadStateChanged);
AddInternal(realmFactory); AddInternal(realmFactory);
dependencies.CacheAs(Storage); dependencies.CacheAs(Storage);
@ -356,6 +363,23 @@ namespace osu.Game
Ruleset.BindValueChanged(onRulesetChanged); Ruleset.BindValueChanged(onRulesetChanged);
} }
private IDisposable blocking;
private void updateThreadStateChanged(ValueChangedEvent<GameThreadState> state)
{
switch (state.NewValue)
{
case GameThreadState.Running:
blocking?.Dispose();
blocking = null;
break;
case GameThreadState.Paused:
blocking = realmFactory.BlockAllOperations();
break;
}
}
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();

View File

@ -10,11 +10,13 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Resources.Localisation.Web;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -23,9 +25,9 @@ namespace osu.Game.Overlays.BeatmapListing
public class BeatmapListingFilterControl : CompositeDrawable public class BeatmapListingFilterControl : CompositeDrawable
{ {
/// <summary> /// <summary>
/// Fired when a search finishes. Contains only new items in the case of pagination. /// Fired when a search finishes.
/// </summary> /// </summary>
public Action<List<BeatmapSetInfo>> SearchFinished; public Action<SearchResult> SearchFinished;
/// <summary> /// <summary>
/// Fired when search criteria change. /// Fired when search criteria change.
@ -212,7 +214,25 @@ namespace osu.Game.Overlays.BeatmapListing
lastResponse = response; lastResponse = response;
getSetsRequest = null; getSetsRequest = null;
SearchFinished?.Invoke(sets); // check if a non-supporter used supporter-only filters
if (!api.LocalUser.Value.IsSupporter)
{
List<LocalisableString> filters = new List<LocalisableString>();
if (searchControl.Played.Value != SearchPlayed.Any)
filters.Add(BeatmapsStrings.ListingSearchFiltersPlayed);
if (searchControl.Ranks.Any())
filters.Add(BeatmapsStrings.ListingSearchFiltersRank);
if (filters.Any())
{
SearchFinished?.Invoke(SearchResult.SupporterOnlyFilters(filters));
return;
}
}
SearchFinished?.Invoke(SearchResult.ResultsReturned(sets));
}; };
api.Queue(getSetsRequest); api.Queue(getSetsRequest);
@ -237,5 +257,53 @@ namespace osu.Game.Overlays.BeatmapListing
base.Dispose(isDisposing); base.Dispose(isDisposing);
} }
/// <summary>
/// Indicates the type of result of a user-requested beatmap search.
/// </summary>
public enum SearchResultType
{
/// <summary>
/// Actual results have been returned from API.
/// </summary>
ResultsReturned,
/// <summary>
/// The user is not a supporter, but used supporter-only search filters.
/// </summary>
SupporterOnlyFilters
}
/// <summary>
/// Describes the result of a user-requested beatmap search.
/// </summary>
public struct SearchResult
{
public SearchResultType Type { get; private set; }
/// <summary>
/// Contains the beatmap sets returned from API.
/// Valid for read if and only if <see cref="Type"/> is <see cref="SearchResultType.ResultsReturned"/>.
/// </summary>
public List<BeatmapSetInfo> Results { get; private set; }
/// <summary>
/// Contains the names of supporter-only filters requested by the user.
/// Valid for read if and only if <see cref="Type"/> is <see cref="SearchResultType.SupporterOnlyFilters"/>.
/// </summary>
public List<LocalisableString> SupporterOnlyFiltersUsed { get; private set; }
public static SearchResult ResultsReturned(List<BeatmapSetInfo> results) => new SearchResult
{
Type = SearchResultType.ResultsReturned,
Results = results
};
public static SearchResult SupporterOnlyFilters(List<LocalisableString> filters) => new SearchResult
{
Type = SearchResultType.SupporterOnlyFilters,
SupporterOnlyFiltersUsed = filters
};
}
} }
} }

View File

@ -3,6 +3,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -22,14 +23,21 @@ namespace osu.Game.Overlays.BeatmapListing
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(RulesetStore rulesets) private void load(RulesetStore rulesets)
{ {
AddItem(new RulesetInfo AddTabItem(new RulesetFilterTabItemAny());
{
Name = @"Any"
});
foreach (var r in rulesets.AvailableRulesets) foreach (var r in rulesets.AvailableRulesets)
AddItem(r); AddItem(r);
} }
} }
private class RulesetFilterTabItemAny : FilterTabItem<RulesetInfo>
{
protected override LocalisableString LabelFor(RulesetInfo info) => BeatmapsStrings.ModeAny;
public RulesetFilterTabItemAny()
: base(new RulesetInfo())
{
}
}
} }
} }

View File

@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Localisation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
@ -15,7 +16,9 @@ using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing;
using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Overlays.BeatmapListing.Panels;
using osu.Game.Resources.Localisation.Web; using osu.Game.Resources.Localisation.Web;
@ -33,6 +36,7 @@ namespace osu.Game.Overlays
private Container panelTarget; private Container panelTarget;
private FillFlowContainer<BeatmapPanel> foundContent; private FillFlowContainer<BeatmapPanel> foundContent;
private NotFoundDrawable notFoundContent; private NotFoundDrawable notFoundContent;
private SupporterRequiredDrawable supporterRequiredContent;
private BeatmapListingFilterControl filterControl; private BeatmapListingFilterControl filterControl;
public BeatmapListingOverlay() public BeatmapListingOverlay()
@ -76,6 +80,7 @@ namespace osu.Game.Overlays
{ {
foundContent = new FillFlowContainer<BeatmapPanel>(), foundContent = new FillFlowContainer<BeatmapPanel>(),
notFoundContent = new NotFoundDrawable(), notFoundContent = new NotFoundDrawable(),
supporterRequiredContent = new SupporterRequiredDrawable(),
} }
} }
}, },
@ -115,9 +120,16 @@ namespace osu.Game.Overlays
private Task panelLoadDelegate; private Task panelLoadDelegate;
private void onSearchFinished(List<BeatmapSetInfo> beatmaps) private void onSearchFinished(BeatmapListingFilterControl.SearchResult searchResult)
{ {
var newPanels = beatmaps.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b) if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters)
{
supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed);
addContentToPlaceholder(supporterRequiredContent);
return;
}
var newPanels = searchResult.Results.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b)
{ {
Anchor = Anchor.TopCentre, Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
@ -128,7 +140,7 @@ namespace osu.Game.Overlays
//No matches case //No matches case
if (!newPanels.Any()) if (!newPanels.Any())
{ {
LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); addContentToPlaceholder(notFoundContent);
return; return;
} }
@ -170,9 +182,9 @@ namespace osu.Game.Overlays
{ {
var transform = lastContent.FadeOut(100, Easing.OutQuint); var transform = lastContent.FadeOut(100, Easing.OutQuint);
if (lastContent == notFoundContent) if (lastContent == notFoundContent || lastContent == supporterRequiredContent)
{ {
// not found display may be used multiple times, so don't expire/dispose it. // the placeholders may be used multiple times, so don't expire/dispose them.
transform.Schedule(() => panelTarget.Remove(lastContent)); transform.Schedule(() => panelTarget.Remove(lastContent));
} }
else else
@ -240,6 +252,67 @@ namespace osu.Game.Overlays
} }
} }
// TODO: localisation requires Text/LinkFlowContainer support for localising strings with links inside
// (https://github.com/ppy/osu-framework/issues/4530)
public class SupporterRequiredDrawable : CompositeDrawable
{
private LinkFlowContainer supporterRequiredText;
public SupporterRequiredDrawable()
{
RelativeSizeAxes = Axes.X;
Height = 225;
Alpha = 0;
}
[BackgroundDependencyLoader]
private void load(TextureStore textures)
{
AddInternal(new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
new Sprite
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Texture = textures.Get(@"Online/supporter-required"),
},
supporterRequiredText = new LinkFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Margin = new MarginPadding { Bottom = 10 },
},
}
});
}
public void UpdateText(List<LocalisableString> filters)
{
supporterRequiredText.Clear();
supporterRequiredText.AddText(
BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filters), "").ToString(),
t =>
{
t.Font = OsuFont.GetFont(size: 16);
t.Colour = Colour4.White;
}
);
supporterRequiredText.AddLink(BeatmapsStrings.ListingSearchSupporterFilterQuoteLinkText.ToString(), @"/store/products/supporter-tag");
}
}
private const double time_between_fetches = 500; private const double time_between_fetches = 500;
private double lastFetchDisplayedTime; private double lastFetchDisplayedTime;

View File

@ -95,7 +95,7 @@ namespace osu.Game.Overlays.BeatmapSet
{ {
private readonly OsuSpriteText value; private readonly OsuSpriteText value;
public string TooltipText { get; } public LocalisableString TooltipText { get; }
public LocalisableString Value public LocalisableString Value
{ {

View File

@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -28,7 +29,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
private readonly IBindable<User> localUser = new Bindable<User>(); private readonly IBindable<User> localUser = new Bindable<User>();
public string TooltipText public LocalisableString TooltipText
{ {
get get
{ {

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
@ -26,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
private readonly bool noVideo; private readonly bool noVideo;
public string TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; public LocalisableString TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download";
private readonly IBindable<User> localUser = new Bindable<User>(); private readonly IBindable<User> localUser = new Bindable<User>();

View File

@ -240,12 +240,15 @@ namespace osu.Game.Overlays.Chat
{ {
get get
{ {
if (sender.Equals(User.SYSTEM_USER))
return Array.Empty<MenuItem>();
List<MenuItem> items = new List<MenuItem> List<MenuItem> items = new List<MenuItem>
{ {
new OsuMenuItem("View Profile", MenuItemType.Highlighted, Action) new OsuMenuItem("View Profile", MenuItemType.Highlighted, Action)
}; };
if (sender.Id != api.LocalUser.Value.Id) if (!sender.Equals(api.LocalUser.Value))
items.Add(new OsuMenuItem("Start Chat", MenuItemType.Standard, startChatAction)); items.Add(new OsuMenuItem("Start Chat", MenuItemType.Standard, startChatAction));
return items.ToArray(); return items.ToArray();

View File

@ -20,6 +20,7 @@ using System;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using System.Collections.Specialized; using System.Collections.Specialized;
using osu.Framework.Localisation;
using osu.Game.Overlays.Comments.Buttons; using osu.Game.Overlays.Comments.Buttons;
namespace osu.Game.Overlays.Comments namespace osu.Game.Overlays.Comments
@ -395,7 +396,7 @@ namespace osu.Game.Overlays.Comments
private class ParentUsername : FillFlowContainer, IHasTooltip private class ParentUsername : FillFlowContainer, IHasTooltip
{ {
public string TooltipText => getParentMessage(); public LocalisableString TooltipText => getParentMessage();
private readonly Comment parentComment; private readonly Comment parentComment;
@ -427,7 +428,7 @@ namespace osu.Game.Overlays.Comments
if (parentComment == null) if (parentComment == null)
return string.Empty; return string.Empty;
return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? @"deleted" : string.Empty; return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? "deleted" : string.Empty;
} }
} }
} }

View File

@ -14,6 +14,7 @@ using System;
using System.Linq; using System.Linq;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -34,7 +35,7 @@ namespace osu.Game.Overlays.Mods
/// </summary> /// </summary>
public Action<Mod> SelectionChanged; public Action<Mod> SelectionChanged;
public string TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty; public LocalisableString TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty;
private const Easing mod_switch_easing = Easing.InOutSine; private const Easing mod_switch_easing = Easing.InOutSine;
private const double mod_switch_duration = 120; private const double mod_switch_duration = 120;

View File

@ -37,9 +37,6 @@ namespace osu.Game.Overlays.Mods
protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CustomiseButton;
protected readonly TriangleButton CloseButton; protected readonly TriangleButton CloseButton;
protected readonly Drawable MultiplierSection;
protected readonly OsuSpriteText MultiplierLabel;
protected readonly FillFlowContainer FooterContainer; protected readonly FillFlowContainer FooterContainer;
protected override bool BlockNonPositionalInput => false; protected override bool BlockNonPositionalInput => false;
@ -324,30 +321,6 @@ namespace osu.Game.Overlays.Mods
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
}, },
MultiplierSection = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(footer_button_spacing / 2, 0),
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Children = new Drawable[]
{
new OsuSpriteText
{
Text = @"Score Multiplier:",
Font = OsuFont.GetFont(size: 30),
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
},
MultiplierLabel = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold),
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
Width = 70, // make width fixed so reflow doesn't occur when multiplier number changes.
},
},
},
} }
} }
}, },
@ -361,11 +334,8 @@ namespace osu.Game.Overlays.Mods
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(OsuColour colours, AudioManager audio, OsuGameBase osu) private void load(AudioManager audio, OsuGameBase osu)
{ {
LowMultiplierColour = colours.Red;
HighMultiplierColour = colours.Green;
availableMods = osu.AvailableMods.GetBoundCopy(); availableMods = osu.AvailableMods.GetBoundCopy();
sampleOn = audio.Samples.Get(@"UI/check-on"); sampleOn = audio.Samples.Get(@"UI/check-on");
@ -495,26 +465,6 @@ namespace osu.Game.Overlays.Mods
foreach (var section in ModSectionsContainer.Children) foreach (var section in ModSectionsContainer.Children)
section.UpdateSelectedButtons(selectedMods); section.UpdateSelectedButtons(selectedMods);
updateMultiplier();
}
private void updateMultiplier()
{
var multiplier = 1.0;
foreach (var mod in SelectedMods.Value)
{
multiplier *= mod.ScoreMultiplier;
}
MultiplierLabel.Text = $"{multiplier:N2}x";
if (multiplier > 1.0)
MultiplierLabel.FadeColour(HighMultiplierColour, 200);
else if (multiplier < 1.0)
MultiplierLabel.FadeColour(LowMultiplierColour, 200);
else
MultiplierLabel.FadeColour(Color4.White, 200);
} }
private void modButtonPressed(Mod selectedMod) private void modButtonPressed(Mod selectedMod)

View File

@ -11,6 +11,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osuTK.Graphics; using osuTK.Graphics;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
namespace osu.Game.Overlays namespace osu.Game.Overlays
{ {
@ -56,7 +57,7 @@ namespace osu.Game.Overlays
[Resolved] [Resolved]
private OverlayColourProvider colourProvider { get; set; } private OverlayColourProvider colourProvider { get; set; }
public string TooltipText => $@"{Value} view"; public LocalisableString TooltipText => $@"{Value} view";
private readonly SpriteIcon icon; private readonly SpriteIcon icon;

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Users; using osu.Game.Users;
using osuTK; using osuTK;
@ -42,6 +43,6 @@ namespace osu.Game.Overlays.Profile.Header.Components
InternalChild.FadeInFromZero(200); InternalChild.FadeInFromZero(200);
} }
public string TooltipText => badge.Description; public LocalisableString TooltipText => badge.Description;
} }
} }

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osuTK; using osuTK;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
@ -14,7 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly BindableBool DetailsVisible = new BindableBool(); public readonly BindableBool DetailsVisible = new BindableBool();
public override string TooltipText => DetailsVisible.Value ? "collapse" : "expand"; public override LocalisableString TooltipText => DetailsVisible.Value ? "collapse" : "expand";
private SpriteIcon icon; private SpriteIcon icon;

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<User> User = new Bindable<User>(); public readonly Bindable<User> User = new Bindable<User>();
public override string TooltipText => "followers"; public override LocalisableString TooltipText => "followers";
protected override IconUsage Icon => FontAwesome.Solid.User; protected override IconUsage Icon => FontAwesome.Solid.User;

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Users; using osu.Game.Users;
@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<User> User = new Bindable<User>(); public readonly Bindable<User> User = new Bindable<User>();
public string TooltipText { get; } public LocalisableString TooltipText { get; }
private OsuSpriteText levelText; private OsuSpriteText levelText;

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<User> User = new Bindable<User>(); public readonly Bindable<User> User = new Bindable<User>();
public string TooltipText { get; } public LocalisableString TooltipText { get; }
private Bar levelProgressBar; private Bar levelProgressBar;
private OsuSpriteText levelProgressText; private OsuSpriteText levelProgressText;

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<User> User = new Bindable<User>(); public readonly Bindable<User> User = new Bindable<User>();
public override string TooltipText => "mapping subscribers"; public override LocalisableString TooltipText => "mapping subscribers";
protected override IconUsage Icon => FontAwesome.Solid.Bell; protected override IconUsage Icon => FontAwesome.Solid.Bell;

View File

@ -5,6 +5,7 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Chat; using osu.Game.Online.Chat;
using osu.Game.Users; using osu.Game.Users;
@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<User> User = new Bindable<User>(); public readonly Bindable<User> User = new Bindable<User>();
public override string TooltipText => "send message"; public override LocalisableString TooltipText => "send message";
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private ChannelManager channelManager { get; set; } private ChannelManager channelManager { get; set; }

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
@ -14,7 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
{ {
public readonly Bindable<User> User = new Bindable<User>(); public readonly Bindable<User> User = new Bindable<User>();
public string TooltipText { get; set; } public LocalisableString TooltipText { get; set; }
private OverlinedInfoContainer info; private OverlinedInfoContainer info;

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
namespace osu.Game.Overlays.Profile.Header.Components namespace osu.Game.Overlays.Profile.Header.Components
@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
private readonly FillFlowContainer iconContainer; private readonly FillFlowContainer iconContainer;
private readonly CircularContainer content; private readonly CircularContainer content;
public string TooltipText => "osu!supporter"; public LocalisableString TooltipText => "osu!supporter";
public int SupportLevel public int SupportLevel
{ {

View File

@ -143,7 +143,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical
private class PlayCountText : CompositeDrawable, IHasTooltip private class PlayCountText : CompositeDrawable, IHasTooltip
{ {
public string TooltipText => "times played"; public LocalisableString TooltipText => "times played";
public PlayCountText(int playCount) public PlayCountText(int playCount)
{ {

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -76,7 +77,7 @@ namespace osu.Game.Overlays
UpdateState(); UpdateState();
} }
public string TooltipText => "revert to default"; public LocalisableString TooltipText => "revert to default";
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)
{ {

View File

@ -0,0 +1,49 @@
// 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.Extensions.Color4Extensions;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osuTK.Graphics;
namespace osu.Game.Overlays.Settings
{
public class OutlinedTextBox : OsuTextBox
{
private const float border_thickness = 3;
private Color4 borderColourFocused;
private Color4 borderColourUnfocused;
[BackgroundDependencyLoader]
private void load(OsuColour colour)
{
borderColourUnfocused = colour.Gray4.Opacity(0.5f);
borderColourFocused = BorderColour;
updateBorder();
}
protected override void OnFocus(FocusEvent e)
{
base.OnFocus(e);
updateBorder();
}
protected override void OnFocusLost(FocusLostEvent e)
{
base.OnFocusLost(e);
updateBorder();
}
private void updateBorder()
{
BorderThickness = border_thickness;
BorderColour = HasFocus ? borderColourFocused : borderColourUnfocused;
}
}
}

View File

@ -3,6 +3,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -32,7 +33,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio
private class OffsetSlider : OsuSliderBar<double> private class OffsetSlider : OsuSliderBar<double>
{ {
public override string TooltipText => Current.Value.ToString(@"0ms"); public override LocalisableString TooltipText => Current.Value.ToString(@"0ms");
} }
} }
} }

View File

@ -233,7 +233,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics
private class UIScaleSlider : OsuSliderBar<float> private class UIScaleSlider : OsuSliderBar<float>
{ {
public override string TooltipText => base.TooltipText + "x"; public override LocalisableString TooltipText => base.TooltipText + "x";
} }
private class ResolutionSettingsDropdown : SettingsDropdown<Size> private class ResolutionSettingsDropdown : SettingsDropdown<Size>

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Configuration; using osu.Framework.Configuration;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Mouse;
using osu.Framework.Localisation;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Input; using osu.Game.Input;
@ -116,7 +117,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private class SensitivitySlider : OsuSliderBar<double> private class SensitivitySlider : OsuSliderBar<double>
{ {
public override string TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; public override LocalisableString TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x";
} }
} }
} }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Settings.Sections namespace osu.Game.Overlays.Settings.Sections
@ -10,6 +11,6 @@ namespace osu.Game.Overlays.Settings.Sections
/// </summary> /// </summary>
internal class SizeSlider : OsuSliderBar<float> internal class SizeSlider : OsuSliderBar<float>
{ {
public override string TooltipText => Current.Value.ToString(@"0.##x"); public override LocalisableString TooltipText => Current.Value.ToString(@"0.##x");
} }
} }

View File

@ -3,6 +3,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -44,7 +45,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
private class TimeSlider : OsuSliderBar<float> private class TimeSlider : OsuSliderBar<float>
{ {
public override string TooltipText => Current.Value.ToString("N0") + "ms"; public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms";
} }
} }
} }

View File

@ -5,6 +5,7 @@ using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -62,12 +63,12 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface
private class MaximumStarsSlider : StarsSlider private class MaximumStarsSlider : StarsSlider
{ {
public override string TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; public override LocalisableString TooltipText => Current.IsDefault ? "no limit" : base.TooltipText;
} }
private class StarsSlider : OsuSliderBar<double> private class StarsSlider : OsuSliderBar<double>
{ {
public override string TooltipText => Current.Value.ToString(@"0.## stars"); public override LocalisableString TooltipText => Current.Value.ToString(@"0.## stars");
} }
} }
} }

View File

@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
namespace osu.Game.Overlays.Settings namespace osu.Game.Overlays.Settings
@ -17,14 +18,15 @@ namespace osu.Game.Overlays.Settings
Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS };
} }
public string TooltipText { get; set; } public LocalisableString TooltipText { get; set; }
public override IEnumerable<string> FilterTerms public override IEnumerable<string> FilterTerms
{ {
get get
{ {
if (TooltipText != null) if (TooltipText != default)
return base.FilterTerms.Append(TooltipText); // TODO: this won't work as intended once the tooltip text is translated.
return base.FilterTerms.Append(TooltipText.ToString());
return base.FilterTerms; return base.FilterTerms;
} }

View File

@ -35,7 +35,7 @@ namespace osu.Game.Overlays.Settings
public bool ShowsDefaultIndicator = true; public bool ShowsDefaultIndicator = true;
public string TooltipText { get; set; } public LocalisableString TooltipText { get; set; }
[Resolved] [Resolved]
private OsuColour colours { get; set; } private OsuColour colours { get; set; }

View File

@ -1,19 +1,65 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
namespace osu.Game.Overlays.Settings namespace osu.Game.Overlays.Settings
{ {
public class SettingsNumberBox : SettingsItem<string> public class SettingsNumberBox : SettingsItem<int?>
{ {
protected override Drawable CreateControl() => new NumberBox protected override Drawable CreateControl() => new NumberControl
{
RelativeSizeAxes = Axes.X,
Margin = new MarginPadding { Top = 5 }
};
private sealed class NumberControl : CompositeDrawable, IHasCurrentValue<int?>
{
private readonly BindableWithCurrent<int?> current = new BindableWithCurrent<int?>();
public Bindable<int?> Current
{
get => current.Current;
set => current.Current = value;
}
public NumberControl()
{
AutoSizeAxes = Axes.Y;
OutlinedNumberBox numberBox;
InternalChildren = new[]
{
numberBox = new OutlinedNumberBox
{ {
Margin = new MarginPadding { Top = 5 }, Margin = new MarginPadding { Top = 5 },
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
CommitOnFocusLost = true
}
}; };
public class NumberBox : SettingsTextBox.TextBox numberBox.Current.BindValueChanged(e =>
{
int? value = null;
if (int.TryParse(e.NewValue, out var intVal))
value = intVal;
current.Value = value;
});
Current.BindValueChanged(e =>
{
numberBox.Current.Value = e.NewValue?.ToString();
});
}
}
private class OutlinedNumberBox : OutlinedTextBox
{ {
protected override bool CanAddCharacter(char character) => char.IsNumber(character); protected override bool CanAddCharacter(char character) => char.IsNumber(character);
} }

View File

@ -1,60 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osuTK.Graphics;
namespace osu.Game.Overlays.Settings namespace osu.Game.Overlays.Settings
{ {
public class SettingsTextBox : SettingsItem<string> public class SettingsTextBox : SettingsItem<string>
{ {
protected override Drawable CreateControl() => new TextBox protected override Drawable CreateControl() => new OutlinedTextBox
{ {
Margin = new MarginPadding { Top = 5 }, Margin = new MarginPadding { Top = 5 },
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
CommitOnFocusLost = true, CommitOnFocusLost = true
}; };
public class TextBox : OsuTextBox
{
private const float border_thickness = 3;
private Color4 borderColourFocused;
private Color4 borderColourUnfocused;
[BackgroundDependencyLoader]
private void load(OsuColour colour)
{
borderColourUnfocused = colour.Gray4.Opacity(0.5f);
borderColourFocused = BorderColour;
updateBorder();
}
protected override void OnFocus(FocusEvent e)
{
base.OnFocus(e);
updateBorder();
}
protected override void OnFocusLost(FocusLostEvent e)
{
base.OnFocusLost(e);
updateBorder();
}
private void updateBorder()
{
BorderThickness = border_thickness;
BorderColour = HasFocus ? borderColourFocused : borderColourUnfocused;
}
}
} }
} }

View File

@ -159,28 +159,6 @@ namespace osu.Game.Overlays.Toolbar
}; };
} }
private RealmKeyBinding realmKeyBinding;
protected override void LoadComplete()
{
base.LoadComplete();
if (Hotkey == null) return;
realmKeyBinding = realmFactory.Context.All<RealmKeyBinding>().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value);
if (realmKeyBinding != null)
{
realmKeyBinding.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(realmKeyBinding.KeyCombinationString))
updateKeyBindingTooltip();
};
}
updateKeyBindingTooltip();
}
protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnClick(ClickEvent e) protected override bool OnClick(ClickEvent e)
@ -196,6 +174,7 @@ namespace osu.Game.Overlays.Toolbar
HoverBackground.FadeIn(200); HoverBackground.FadeIn(200);
tooltipContainer.FadeIn(100); tooltipContainer.FadeIn(100);
return base.OnHover(e); return base.OnHover(e);
} }
@ -222,6 +201,10 @@ namespace osu.Game.Overlays.Toolbar
private void updateKeyBindingTooltip() private void updateKeyBindingTooltip()
{ {
if (Hotkey == null) return;
var realmKeyBinding = realmFactory.Context.All<RealmKeyBinding>().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value);
if (realmKeyBinding != null) if (realmKeyBinding != null)
{ {
var keyBindingString = realmKeyBinding.KeyCombination.ReadableString(); var keyBindingString = realmKeyBinding.KeyCombination.ReadableString();

View File

@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Edit
// Audio // Audio
new CheckAudioPresence(), new CheckAudioPresence(),
new CheckAudioQuality(), new CheckAudioQuality(),
new CheckMutedObjects(),
new CheckFewHitsounds(),
// Compose // Compose
new CheckUnsnappedObjects(), new CheckUnsnappedObjects(),

View File

@ -0,0 +1,164 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Audio;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckFewHitsounds : ICheck
{
/// <summary>
/// 2 measures (4/4) of 120 BPM, typically makes up a few patterns in the map.
/// This is almost always ok, but can still be useful for the mapper to make sure hitsounding coverage is good.
/// </summary>
private const int negligible_threshold_time = 4000;
/// <summary>
/// 4 measures (4/4) of 120 BPM, typically makes up a large portion of a section in the song.
/// This is ok if the section is a quiet intro, for example.
/// </summary>
private const int warning_threshold_time = 8000;
/// <summary>
/// 12 measures (4/4) of 120 BPM, typically makes up multiple sections in the song.
/// </summary>
private const int problem_threshold_time = 24000;
// Should pass at least this many objects without hitsounds to be considered an issue (should work for Easy diffs too).
private const int warning_threshold_objects = 4;
private const int problem_threshold_objects = 16;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Few or no hitsounds");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateLongPeriodProblem(this),
new IssueTemplateLongPeriodWarning(this),
new IssueTemplateLongPeriodNegligible(this),
new IssueTemplateNoHitsounds(this)
};
private bool mapHasHitsounds;
private int objectsWithoutHitsounds;
private double lastHitsoundTime;
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
if (!context.Beatmap.HitObjects.Any())
yield break;
mapHasHitsounds = false;
objectsWithoutHitsounds = 0;
lastHitsoundTime = context.Beatmap.HitObjects.First().StartTime;
var hitObjectsIncludingNested = new List<HitObject>();
foreach (var hitObject in context.Beatmap.HitObjects)
{
// Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat).
foreach (var nestedHitObject in hitObject.NestedHitObjects)
hitObjectsIncludingNested.Add(nestedHitObject);
hitObjectsIncludingNested.Add(hitObject);
}
var hitObjectsByEndTime = hitObjectsIncludingNested.OrderBy(o => o.GetEndTime()).ToList();
var hitObjectCount = hitObjectsByEndTime.Count;
for (int i = 0; i < hitObjectCount; ++i)
{
var hitObject = hitObjectsByEndTime[i];
// This is used to perform an update at the end so that the period after the last hitsounded object can be an issue.
bool isLastObject = i == hitObjectCount - 1;
foreach (var issue in applyHitsoundUpdate(hitObject, isLastObject))
yield return issue;
}
if (!mapHasHitsounds)
yield return new IssueTemplateNoHitsounds(this).Create();
}
private IEnumerable<Issue> applyHitsoundUpdate(HitObject hitObject, bool isLastObject = false)
{
var time = hitObject.GetEndTime();
bool hasHitsound = hitObject.Samples.Any(isHitsound);
bool couldHaveHitsound = hitObject.Samples.Any(isHitnormal);
// Only generating issues on hitsounded or last objects ensures we get one issue per long period.
// If there are no hitsounds we let the "No hitsounds" template take precedence.
if (hasHitsound || (isLastObject && mapHasHitsounds))
{
var timeWithoutHitsounds = time - lastHitsoundTime;
if (timeWithoutHitsounds > problem_threshold_time && objectsWithoutHitsounds > problem_threshold_objects)
yield return new IssueTemplateLongPeriodProblem(this).Create(lastHitsoundTime, timeWithoutHitsounds);
else if (timeWithoutHitsounds > warning_threshold_time && objectsWithoutHitsounds > warning_threshold_objects)
yield return new IssueTemplateLongPeriodWarning(this).Create(lastHitsoundTime, timeWithoutHitsounds);
else if (timeWithoutHitsounds > negligible_threshold_time && objectsWithoutHitsounds > warning_threshold_objects)
yield return new IssueTemplateLongPeriodNegligible(this).Create(lastHitsoundTime, timeWithoutHitsounds);
}
if (hasHitsound)
{
mapHasHitsounds = true;
objectsWithoutHitsounds = 0;
lastHitsoundTime = time;
}
else if (couldHaveHitsound)
++objectsWithoutHitsounds;
}
private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.AllAdditions.Any(sample.Name.Contains);
private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL);
public abstract class IssueTemplateLongPeriod : IssueTemplate
{
protected IssueTemplateLongPeriod(ICheck check, IssueType type)
: base(check, type, "Long period without hitsounds ({0:F1} seconds).")
{
}
public Issue Create(double time, double duration) => new Issue(this, duration / 1000f) { Time = time };
}
public class IssueTemplateLongPeriodProblem : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodProblem(ICheck check)
: base(check, IssueType.Problem)
{
}
}
public class IssueTemplateLongPeriodWarning : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodWarning(ICheck check)
: base(check, IssueType.Warning)
{
}
}
public class IssueTemplateLongPeriodNegligible : IssueTemplateLongPeriod
{
public IssueTemplateLongPeriodNegligible(ICheck check)
: base(check, IssueType.Negligible)
{
}
}
public class IssueTemplateNoHitsounds : IssueTemplate
{
public IssueTemplateNoHitsounds(ICheck check)
: base(check, IssueType.Problem, "There are no hitsounds.")
{
}
public Issue Create() => new Issue(this);
}
}
}

View File

@ -0,0 +1,158 @@
// 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 osu.Framework.Utils;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckMutedObjects : ICheck
{
/// <summary>
/// Volume percentages lower than or equal to this are typically inaudible.
/// </summary>
private const int muted_threshold = 5;
/// <summary>
/// Volume percentages lower than or equal to this can sometimes be inaudible depending on sample used and music volume.
/// </summary>
private const int low_volume_threshold = 20;
private enum EdgeType
{
Head,
Repeat,
Tail,
None
}
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Low volume hitobjects");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateMutedActive(this),
new IssueTemplateLowVolumeActive(this),
new IssueTemplateMutedPassive(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
foreach (var hitObject in context.Beatmap.HitObjects)
{
// Worth keeping in mind: The samples of an object always play at its end time.
// Objects like spinners have no sound at its start because of this, while hold notes have nested objects to accomplish this.
foreach (var nestedHitObject in hitObject.NestedHitObjects)
{
foreach (var issue in getVolumeIssues(hitObject, nestedHitObject))
yield return issue;
}
foreach (var issue in getVolumeIssues(hitObject))
yield return issue;
}
}
private IEnumerable<Issue> getVolumeIssues(HitObject hitObject, HitObject sampledHitObject = null)
{
sampledHitObject ??= hitObject;
if (!sampledHitObject.Samples.Any())
yield break;
// Samples that allow themselves to be overridden by control points have a volume of 0.
int maxVolume = sampledHitObject.Samples.Max(sample => sample.Volume > 0 ? sample.Volume : sampledHitObject.SampleControlPoint.SampleVolume);
double samplePlayTime = sampledHitObject.GetEndTime();
EdgeType edgeType = getEdgeAtTime(hitObject, samplePlayTime);
// We only care about samples played on the edges of objects, not ones like spinnerspin or slidertick.
if (edgeType == EdgeType.None)
yield break;
string postfix = hitObject is IHasDuration ? edgeType.ToString().ToLower() : null;
if (maxVolume <= muted_threshold)
{
if (edgeType == EdgeType.Head)
yield return new IssueTemplateMutedActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix);
else
yield return new IssueTemplateMutedPassive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix);
}
else if (maxVolume <= low_volume_threshold && edgeType == EdgeType.Head)
{
yield return new IssueTemplateLowVolumeActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix);
}
}
private EdgeType getEdgeAtTime(HitObject hitObject, double time)
{
if (Precision.AlmostEquals(time, hitObject.StartTime, 1f))
return EdgeType.Head;
if (Precision.AlmostEquals(time, hitObject.GetEndTime(), 1f))
return EdgeType.Tail;
if (hitObject is IHasRepeats hasRepeats)
{
double spanDuration = hasRepeats.Duration / hasRepeats.SpanCount();
if (spanDuration <= 0)
// Prevents undefined behaviour in cases like where zero/negative-length sliders/hold notes exist.
return EdgeType.None;
double spans = (time - hitObject.StartTime) / spanDuration;
double acceptableDifference = 1 / spanDuration; // 1 ms of acceptable difference, as with head/tail above.
if (Precision.AlmostEquals(spans, Math.Ceiling(spans), acceptableDifference) ||
Precision.AlmostEquals(spans, Math.Floor(spans), acceptableDifference))
{
return EdgeType.Repeat;
}
}
return EdgeType.None;
}
public abstract class IssueTemplateMuted : IssueTemplate
{
protected IssueTemplateMuted(ICheck check, IssueType type, string unformattedMessage)
: base(check, type, unformattedMessage)
{
}
public Issue Create(HitObject hitobject, double volume, double time, string postfix = "")
{
string objectName = hitobject.GetType().Name;
if (!string.IsNullOrEmpty(postfix))
objectName += " " + postfix;
return new Issue(hitobject, this, objectName, volume) { Time = time };
}
}
public class IssueTemplateMutedActive : IssueTemplateMuted
{
public IssueTemplateMutedActive(ICheck check)
: base(check, IssueType.Problem, "{0} has a volume of {1:0%}. Clickable objects must have clearly audible feedback.")
{
}
}
public class IssueTemplateLowVolumeActive : IssueTemplateMuted
{
public IssueTemplateLowVolumeActive(ICheck check)
: base(check, IssueType.Warning, "{0} has a volume of {1:0%}, ensure this is audible.")
{
}
}
public class IssueTemplateMutedPassive : IssueTemplateMuted
{
public IssueTemplateMutedPassive(ICheck check)
: base(check, IssueType.Negligible, "{0} has a volume of {1:0%}, ensure there is no distinct sound here in the song if inaudible.")
{
}
}
}
}

View File

@ -5,6 +5,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Overlays.Settings;
namespace osu.Game.Rulesets.Mods namespace osu.Game.Rulesets.Mods
{ {
@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => OsuIcon.Dice; public override IconUsage? Icon => OsuIcon.Dice;
public override double ScoreMultiplier => 1; public override double ScoreMultiplier => 1;
[SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SeedSettingsControl))] [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?> public Bindable<int?> Seed { get; } = new Bindable<int?>
{ {
Default = null, Default = null,

View File

@ -1,92 +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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings;
namespace osu.Game.Rulesets.Mods
{
/// <summary>
/// A settings control for use by <see cref="IHasSeed"/> mods which have a customisable seed value.
/// </summary>
public class SeedSettingsControl : SettingsItem<int?>
{
protected override Drawable CreateControl() => new SeedControl
{
RelativeSizeAxes = Axes.X,
Margin = new MarginPadding { Top = 5 }
};
private sealed class SeedControl : CompositeDrawable, IHasCurrentValue<int?>
{
private readonly BindableWithCurrent<int?> current = new BindableWithCurrent<int?>();
public Bindable<int?> Current
{
get => current;
set
{
current.Current = value;
seedNumberBox.Text = value.Value.ToString();
}
}
private readonly OsuNumberBox seedNumberBox;
public SeedControl()
{
AutoSizeAxes = Axes.Y;
InternalChildren = new[]
{
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.Absolute, 2),
new Dimension(GridSizeMode.Relative, 0.25f)
},
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize)
},
Content = new[]
{
new Drawable[]
{
seedNumberBox = new OsuNumberBox
{
RelativeSizeAxes = Axes.X,
CommitOnFocusLost = true
}
}
}
}
};
seedNumberBox.Current.BindValueChanged(e =>
{
int? value = null;
if (int.TryParse(e.NewValue, out var intVal))
value = intVal;
current.Value = value;
});
}
protected override void Update()
{
if (current.Value == null)
seedNumberBox.Text = current.Current.Value.ToString();
}
}
}
}

View File

@ -13,6 +13,7 @@ using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osuTK; using osuTK;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Localisation;
namespace osu.Game.Rulesets.UI namespace osu.Game.Rulesets.UI
{ {
@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.UI
private const float size = 80; private const float size = 80;
public virtual string TooltipText => showTooltip ? mod.IconTooltip : null; public virtual LocalisableString TooltipText => showTooltip ? mod.IconTooltip : null;
private Mod mod; private Mod mod;
private readonly bool showTooltip; private readonly bool showTooltip;

View File

@ -46,7 +46,7 @@ namespace osu.Game.Scoring
[JsonIgnore] [JsonIgnore]
public int Combo { get; set; } // Todo: Shouldn't exist in here public int Combo { get; set; } // Todo: Shouldn't exist in here
[JsonIgnore] [JsonProperty("ruleset_id")]
public int RulesetID { get; set; } public int RulesetID { get; set; }
[JsonProperty("passed")] [JsonProperty("passed")]

View File

@ -7,6 +7,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -72,6 +73,9 @@ namespace osu.Game.Scoring
} }
} }
protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
protected override void ExportModelTo(ScoreInfo model, Stream outputStream) protected override void ExportModelTo(ScoreInfo model, Stream outputStream)
{ {
var file = model.Files.SingleOrDefault(); var file = model.Files.SingleOrDefault();

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -58,6 +59,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint);
} }
public string TooltipText { get; } public LocalisableString TooltipText { get; }
} }
} }

View File

@ -261,7 +261,7 @@ namespace osu.Game.Screens.Menu
switch (state) switch (state)
{ {
default: default:
return true; return false;
case ButtonSystemState.Initial: case ButtonSystemState.Initial:
State = ButtonSystemState.TopLevel; State = ButtonSystemState.TopLevel;

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
@ -16,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
{ {
private readonly GameType type; private readonly GameType type;
public string TooltipText => type.Name; public LocalisableString TooltipText => type.Name;
public DrawableGameType(GameType type) public DrawableGameType(GameType type)
{ {

View File

@ -32,7 +32,6 @@ namespace osu.Game.Screens.OnlinePlay
{ {
IsValidMod = m => true; IsValidMod = m => true;
MultiplierSection.Alpha = 0;
DeselectAllButton.Alpha = 0; DeselectAllButton.Alpha = 0;
Drawable selectAllButton; Drawable selectAllButton;

View File

@ -431,7 +431,7 @@ namespace osu.Game.Screens.Select
public class InfoLabel : Container, IHasTooltip public class InfoLabel : Container, IHasTooltip
{ {
public string TooltipText { get; } public LocalisableString TooltipText { get; }
public InfoLabel(BeatmapStatistic statistic) public InfoLabel(BeatmapStatistic statistic)
{ {

View File

@ -42,6 +42,7 @@ namespace osu.Game.Screens.Select.Carousel
match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(Beatmap.BaseDifficulty.ApproachRate); match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(Beatmap.BaseDifficulty.ApproachRate);
match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(Beatmap.BaseDifficulty.DrainRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(Beatmap.BaseDifficulty.DrainRate);
match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(Beatmap.BaseDifficulty.CircleSize); match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(Beatmap.BaseDifficulty.CircleSize);
match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(Beatmap.BaseDifficulty.OverallDifficulty);
match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(Beatmap.Length); match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(Beatmap.Length);
match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(Beatmap.BPM); match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(Beatmap.BPM);

View File

@ -24,6 +24,7 @@ namespace osu.Game.Screens.Select
public OptionalRange<float> ApproachRate; public OptionalRange<float> ApproachRate;
public OptionalRange<float> DrainRate; public OptionalRange<float> DrainRate;
public OptionalRange<float> CircleSize; public OptionalRange<float> CircleSize;
public OptionalRange<float> OverallDifficulty;
public OptionalRange<double> Length; public OptionalRange<double> Length;
public OptionalRange<double> BPM; public OptionalRange<double> BPM;
public OptionalRange<int> BeatDivisor; public OptionalRange<int> BeatDivisor;

View File

@ -51,6 +51,9 @@ namespace osu.Game.Screens.Select
case "cs": case "cs":
return TryUpdateCriteriaRange(ref criteria.CircleSize, op, value); return TryUpdateCriteriaRange(ref criteria.CircleSize, op, value);
case "od":
return TryUpdateCriteriaRange(ref criteria.OverallDifficulty, op, value);
case "bpm": case "bpm":
return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2); return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2);

View File

@ -42,27 +42,30 @@ namespace osu.Game.Skinning
}; };
} }
[Resolved] private ISkinSource parentSource;
private ISkinSource skinSource { get; set; }
[BackgroundDependencyLoader] protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
private void load()
{ {
UpdateSkins(); parentSource = parent.Get<ISkinSource>();
skinSource.SourceChanged += OnSourceChanged; parentSource.SourceChanged += OnSourceChanged;
// ensure sources are populated and ready for use before childrens' asynchronous load flow.
UpdateSkinSources();
return base.CreateChildDependencies(parent);
} }
protected override void OnSourceChanged() protected override void OnSourceChanged()
{ {
UpdateSkins(); UpdateSkinSources();
base.OnSourceChanged(); base.OnSourceChanged();
} }
protected virtual void UpdateSkins() protected virtual void UpdateSkinSources()
{ {
SkinSources.Clear(); SkinSources.Clear();
foreach (var skin in skinSource.AllSources) foreach (var skin in parentSource.AllSources)
{ {
switch (skin) switch (skin)
{ {
@ -93,8 +96,8 @@ namespace osu.Game.Skinning
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (skinSource != null) if (parentSource != null)
skinSource.SourceChanged -= OnSourceChanged; parentSource.SourceChanged -= OnSourceChanged;
} }
} }
} }

View File

@ -125,6 +125,8 @@ namespace osu.Game.Skinning
private const string unknown_creator_string = "Unknown"; private const string unknown_creator_string = "Unknown";
protected override bool HasCustomHashFunction => true;
protected override string ComputeHash(SkinInfo item, ArchiveReader reader = null) protected override string ComputeHash(SkinInfo item, ArchiveReader reader = null)
{ {
// we need to populate early to create a hash based off skin.ini contents // we need to populate early to create a hash based off skin.ini contents
@ -142,16 +144,16 @@ namespace osu.Game.Skinning
return base.ComputeHash(item, reader); return base.ComputeHash(item, reader);
} }
protected override async Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
{ {
await base.Populate(model, archive, cancellationToken).ConfigureAwait(false);
var instance = GetSkin(model); var instance = GetSkin(model);
model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo();
if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true)
populateMetadata(model, instance); populateMetadata(model, instance);
return Task.CompletedTask;
} }
private void populateMetadata(SkinInfo item, Skin instance) private void populateMetadata(SkinInfo item, Skin instance)

View File

@ -6,6 +6,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
namespace osu.Game.Users.Drawables namespace osu.Game.Users.Drawables
@ -68,11 +69,11 @@ namespace osu.Game.Users.Drawables
private class ClickableArea : OsuClickableContainer private class ClickableArea : OsuClickableContainer
{ {
private string tooltip = default_tooltip_text; private LocalisableString tooltip = default_tooltip_text;
public override string TooltipText public override LocalisableString TooltipText
{ {
get => Enabled.Value ? tooltip : null; get => Enabled.Value ? tooltip : default;
set => tooltip = value; set => tooltip = value;
} }

View File

@ -6,6 +6,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
namespace osu.Game.Users.Drawables namespace osu.Game.Users.Drawables
{ {
@ -13,7 +14,7 @@ namespace osu.Game.Users.Drawables
{ {
private readonly Country country; private readonly Country country;
public string TooltipText => country?.FullName; public LocalisableString TooltipText => country?.FullName;
public DrawableFlag(Country country) public DrawableFlag(Country country)
{ {

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="10.2.0" /> <PackageReference Include="Realm" Version="10.2.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.622.0" /> <PackageReference Include="ppy.osu.Framework" Version="2021.628.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
<PackageReference Include="Sentry" Version="3.4.0" /> <PackageReference Include="Sentry" Version="3.4.0" />
<PackageReference Include="SharpCompress" Version="0.28.2" /> <PackageReference Include="SharpCompress" Version="0.28.2" />

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