1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-13 04:32:57 +08:00

Merge branch 'master' into legacy-body-piece-crash

This commit is contained in:
Salman Ahmed 2024-02-16 00:30:36 +03:00 committed by GitHub
commit 3108842a1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 2020 additions and 586 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -1,111 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Skinning;
using osu.Game.Tests.Visual;
using Direction = osu.Game.Rulesets.Catch.UI.Direction;
namespace osu.Game.Rulesets.Catch.Tests
{
public partial class TestSceneCatchSkinConfiguration : OsuTestScene
{
private Catcher catcher;
private readonly Container container;
public TestSceneCatchSkinConfiguration()
{
Add(container = new Container { RelativeSizeAxes = Axes.Both });
}
[TestCase(false)]
[TestCase(true)]
public void TestCatcherPlateFlipping(bool flip)
{
AddStep("setup catcher", () =>
{
var skin = new TestSkin { FlipCatcherPlate = flip };
container.Child = new SkinProvidingContainer(skin)
{
Child = catcher = new Catcher(new DroppedObjectContainer())
{
Anchor = Anchor.Centre
}
};
});
Fruit fruit = new Fruit();
AddStep("catch fruit", () => catchFruit(fruit, 20));
float position = 0;
AddStep("record fruit position", () => position = getCaughtObjectPosition(fruit));
AddStep("face left", () => catcher.VisualDirection = Direction.Left);
if (flip)
AddAssert("fruit position changed", () => !Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
else
AddAssert("fruit position unchanged", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
AddStep("face right", () => catcher.VisualDirection = Direction.Right);
AddAssert("fruit position restored", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position));
}
private float getCaughtObjectPosition(Fruit fruit)
{
var caughtObject = catcher.ChildrenOfType<CaughtObject>().Single(c => c.HitObject == fruit);
return caughtObject.Parent!.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X;
}
private void catchFruit(Fruit fruit, float x)
{
fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
var drawableFruit = new DrawableFruit(fruit) { X = x };
var judgement = fruit.CreateJudgement();
catcher.OnNewResult(drawableFruit, new CatchJudgementResult(fruit, judgement)
{
Type = judgement.MaxResult
});
}
private class TestSkin : TrianglesSkin
{
public bool FlipCatcherPlate { get; set; }
public TestSkin()
: base(null!)
{
}
public override IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
{
if (lookup is CatchSkinConfiguration config)
{
if (config == CatchSkinConfiguration.FlipCatcherPlate)
return SkinUtils.As<TValue>(new Bindable<bool>(FlipCatcherPlate));
}
return base.GetConfig<TLookup, TValue>(lookup);
}
}
}
}

View File

@ -21,7 +21,6 @@ namespace osu.Game.Rulesets.Catch.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{
drawableRuleset.PlayfieldAdjustmentContainer.Scale = new Vector2(1, -1);
drawableRuleset.PlayfieldAdjustmentContainer.Y = 1 - drawableRuleset.PlayfieldAdjustmentContainer.Y;
}
}
}

View File

@ -1,13 +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.
namespace osu.Game.Rulesets.Catch.Skinning
{
public enum CatchSkinConfiguration
{
/// <summary>
/// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
/// </summary>
FlipCatcherPlate
}
}

View File

@ -122,19 +122,6 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
result.Value = LegacyColourCompatibility.DisallowZeroAlpha(result.Value);
return (IBindable<TValue>)result;
case CatchSkinConfiguration config:
switch (config)
{
case CatchSkinConfiguration.FlipCatcherPlate:
// Don't flip catcher plate contents if the catcher is provided by this legacy skin.
if (GetDrawableComponent(new CatchSkinComponentLookup(CatchSkinComponents.Catcher)) != null)
return (IBindable<TValue>)new Bindable<bool>();
break;
}
break;
}
return base.GetConfig<TLookup, TValue>(lookup);

View File

@ -17,24 +17,36 @@ namespace osu.Game.Rulesets.Catch.UI
public CatchPlayfieldAdjustmentContainer()
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
const float base_game_width = 1024f;
const float base_game_height = 768f;
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
// we can match that in lazer by using relative coordinates for Y and considering window height to be 1, and playfield height to be 0.8.
RelativePositionAxes = Axes.Y;
Y = (1 - playfield_size_adjust) / 4 * 3;
// extra bottom space for the catcher to not get cut off at tall resolutions lower than 4:3 (e.g. 5:4). number chosen based on testing with maximum catcher scale (i.e. CS 0).
const float extra_bottom_space = 200f;
Size = new Vector2(playfield_size_adjust);
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
InternalChild = new Container
{
// This container limits vertical visibility of the playfield to ensure fairness between wide and tall resolutions (i.e. tall resolutions should not see more fruits).
// Note that the container still extends across the screen horizontally, so that hit explosions at the sides of the playfield do not get cut off.
Name = "Visible area",
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
FillAspectRatio = 4f / 3,
Child = content = new ScalingContainer { RelativeSizeAxes = Axes.Both, }
RelativeSizeAxes = Axes.X,
Height = base_game_height + extra_bottom_space,
Y = extra_bottom_space / 2,
Masking = true,
Child = new Container
{
Name = "Playable area",
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
Y = base_game_height * ((1 - playfield_size_adjust) / 4 * 3),
Size = new Vector2(base_game_width, base_game_height) * playfield_size_adjust,
Child = content = new ScalingContainer { RelativeSizeAxes = Axes.Both }
},
};
}

View File

@ -112,11 +112,6 @@ namespace osu.Game.Rulesets.Catch.UI
public Vector2 BodyScale => Scale * body.Scale;
/// <summary>
/// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed.
/// </summary>
private bool flipCatcherPlate;
/// <summary>
/// Width of the area that can be used to attempt catches during gameplay.
/// </summary>
@ -339,8 +334,6 @@ namespace osu.Game.Rulesets.Catch.UI
skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDash)?.Value ??
DEFAULT_HYPER_DASH_COLOUR;
flipCatcherPlate = skin.GetConfig<CatchSkinConfiguration, bool>(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true;
runHyperDashStateTransition(HyperDashing);
}
@ -352,8 +345,7 @@ namespace osu.Game.Rulesets.Catch.UI
body.Scale = scaleFromDirection;
// Inverse of catcher scale is applied here, as catcher gets scaled by circle size and so do the incoming fruit.
caughtObjectContainer.Scale = (1 / Scale.X) * (flipCatcherPlate ? scaleFromDirection : Vector2.One);
hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One;
caughtObjectContainer.Scale = new Vector2(1 / Scale.X);
// Correct overshooting.
if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) ||

View File

@ -1,8 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
@ -11,9 +21,80 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
{
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[TestCase(0.5f)]
[TestCase(0.1f)]
[TestCase(0.7f)]
public void TestCoverage(float coverage) => CreateModTest(new ModTestData { Mod = new ManiaModFadeIn { Coverage = { Value = coverage } }, PassCondition = () => true });
[Test]
public void TestMinCoverageFullWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MIN_COVERAGE)
});
}
[Test]
public void TestMinCoverageHalfWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MIN_COVERAGE)
});
AddStep("set playfield width to 0.5", () => Player.Width = 0.5f);
}
[Test]
public void TestMaxCoverageFullWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MAX_COVERAGE)
});
AddStep("set combo to 480", () => Player.ScoreProcessor.Combo.Value = 480);
}
[Test]
public void TestMaxCoverageHalfWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MAX_COVERAGE)
});
AddStep("set combo to 480", () => Player.ScoreProcessor.Combo.Value = 480);
AddStep("set playfield width to 0.5", () => Player.Width = 0.5f);
}
[Test]
public void TestNoCoverageDuringBreak()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
Beatmap = new Beatmap
{
HitObjects = Enumerable.Range(1, 100).Select(i => (HitObject)new Note { StartTime = 1000 + 200 * i }).ToList(),
Breaks = { new BreakPeriod(2000, 28000) }
},
PassCondition = () => Player.IsBreakTime.Value && checkCoverage(0)
});
}
private bool checkCoverage(float expected)
{
Drawable? cover = this.ChildrenOfType<PlayfieldCoveringWrapper>().FirstOrDefault();
Drawable? filledArea = cover?.ChildrenOfType<Box>().LastOrDefault();
if (filledArea == null)
return false;
float scale = cover!.DrawHeight / (768 - Stage.HIT_TARGET_POSITION);
// A bit of lenience because the test may end up hitting hitobjects before any assertions.
return Precision.AlmostEquals(filledArea.DrawHeight / scale, expected, 0.1);
}
}
}

View File

@ -1,8 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
@ -11,9 +21,80 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
{
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[TestCase(0.5f)]
[TestCase(0.2f)]
[TestCase(0.8f)]
public void TestCoverage(float coverage) => CreateModTest(new ModTestData { Mod = new ManiaModHidden { Coverage = { Value = coverage } }, PassCondition = () => true });
[Test]
public void TestMinCoverageFullWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MIN_COVERAGE)
});
}
[Test]
public void TestMinCoverageHalfWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MIN_COVERAGE)
});
AddStep("set playfield width to 0.5", () => Player.Width = 0.5f);
}
[Test]
public void TestMaxCoverageFullWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MAX_COVERAGE)
});
AddStep("set combo to 480", () => Player.ScoreProcessor.Combo.Value = 480);
}
[Test]
public void TestMaxCoverageHalfWidth()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
PassCondition = () => checkCoverage(ManiaModHidden.MAX_COVERAGE)
});
AddStep("set combo to 480", () => Player.ScoreProcessor.Combo.Value = 480);
AddStep("set playfield width to 0.5", () => Player.Width = 0.5f);
}
[Test]
public void TestNoCoverageDuringBreak()
{
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
Beatmap = new Beatmap
{
HitObjects = Enumerable.Range(1, 100).Select(i => (HitObject)new Note { StartTime = 1000 + 200 * i }).ToList(),
Breaks = { new BreakPeriod(2000, 28000) }
},
PassCondition = () => Player.IsBreakTime.Value && checkCoverage(0)
});
}
private bool checkCoverage(float expected)
{
Drawable? cover = this.ChildrenOfType<PlayfieldCoveringWrapper>().FirstOrDefault();
Drawable? filledArea = cover?.ChildrenOfType<Box>().LastOrDefault();
if (filledArea == null)
return false;
float scale = cover!.DrawHeight / (768 - Stage.HIT_TARGET_POSITION);
// A bit of lenience because the test may end up hitting hitobjects before any assertions.
return Precision.AlmostEquals(filledArea.DrawHeight / scale, expected, 0.1);
}
}
}

View File

@ -39,18 +39,18 @@ namespace osu.Game.Rulesets.Mania.Tests
public void TestScrollingDownwards()
{
AddStep("set down scroll", () => scrollingContainer.Direction = ScrollingDirection.Down);
AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f);
AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f);
AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f);
AddStep("set coverage = 0.5", () => cover.Coverage.Value = 0.5f);
AddStep("set coverage = 0.8f", () => cover.Coverage.Value = 0.8f);
AddStep("set coverage = 0.2f", () => cover.Coverage.Value = 0.2f);
}
[Test]
public void TestScrollingUpwards()
{
AddStep("set up scroll", () => scrollingContainer.Direction = ScrollingDirection.Up);
AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f);
AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f);
AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f);
AddStep("set coverage = 0.5", () => cover.Coverage.Value = 0.5f);
AddStep("set coverage = 0.8f", () => cover.Coverage.Value = 0.8f);
AddStep("set coverage = 0.2f", () => cover.Coverage.Value = 0.2f);
}
}
}

View File

@ -247,7 +247,7 @@ namespace osu.Game.Rulesets.Mania
new ManiaModHardRock(),
new MultiMod(new ManiaModSuddenDeath(), new ManiaModPerfect()),
new MultiMod(new ManiaModDoubleTime(), new ManiaModNightcore()),
new MultiMod(new ManiaModFadeIn(), new ManiaModHidden()),
new MultiMod(new ManiaModFadeIn(), new ManiaModHidden(), new ManiaModCover()),
new ManiaModFlashlight(),
new ModAccuracyChallenge(),
};

View File

@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModCover : ManiaModWithPlayfieldCover
{
public override string Name => "Cover";
public override string Acronym => "CO";
public override LocalisableString Description => @"Decrease the playfield's viewing area.";
public override double ScoreMultiplier => 1;
protected override CoverExpandDirection ExpandDirection => Direction.Value;
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
typeof(ManiaModHidden),
typeof(ManiaModFadeIn)
}).ToArray();
public override bool Ranked => false;
[SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")]
public override BindableNumber<float> Coverage { get; } = new BindableFloat(0.5f)
{
Precision = 0.1f,
MinValue = 0.2f,
MaxValue = 0.8f,
Default = 0.5f,
};
[SettingSource("Direction", "The direction on which the cover is applied")]
public Bindable<CoverExpandDirection> Direction { get; } = new Bindable<CoverExpandDirection>();
}
}

View File

@ -3,29 +3,24 @@
using System;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModFadeIn : ManiaModPlayfieldCover
public class ManiaModFadeIn : ManiaModHidden
{
public override string Name => "Fade In";
public override string Acronym => "FI";
public override LocalisableString Description => @"Keys appear out of nowhere!";
public override double ScoreMultiplier => 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModHidden)).ToArray();
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
typeof(ManiaModHidden),
typeof(ManiaModCover)
}).ToArray();
protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll;
public override BindableNumber<float> Coverage { get; } = new BindableFloat(0.5f)
{
Precision = 0.1f,
MinValue = 0.1f,
MaxValue = 0.7f,
Default = 0.5f,
};
}
}

View File

@ -3,27 +3,104 @@
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Localisation;
using osu.Game.Rulesets.Mania.UI;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Skinning;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.Mods
{
public class ManiaModHidden : ManiaModPlayfieldCover
public partial class ManiaModHidden : ManiaModWithPlayfieldCover, IApplicableToPlayer, IUpdatableByPlayfield
{
/// <summary>
/// osu!stable is referenced to 768px.
/// </summary>
private const float reference_playfield_height = 768;
public const float MIN_COVERAGE = 160f;
public const float MAX_COVERAGE = 400f;
private const float coverage_increase_per_combo = 0.5f;
public override LocalisableString Description => @"Keys fade out before you hit them!";
public override double ScoreMultiplier => 1;
public override BindableNumber<float> Coverage { get; } = new BindableFloat(0.5f)
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[]
{
Precision = 0.1f,
MinValue = 0.2f,
MaxValue = 0.8f,
Default = 0.5f,
};
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModFadeIn)).ToArray();
typeof(ManiaModFadeIn),
typeof(ManiaModCover)
}).ToArray();
public override BindableNumber<float> Coverage { get; } = new BindableFloat(MIN_COVERAGE);
protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll;
private readonly IBindable<bool> isBreakTime = new Bindable<bool>();
private readonly BindableInt combo = new BindableInt();
public override void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
base.ApplyToScoreProcessor(scoreProcessor);
combo.UnbindAll();
combo.BindTo(scoreProcessor.Combo);
}
public void ApplyToPlayer(Player player)
{
isBreakTime.UnbindAll();
isBreakTime.BindTo(player.IsBreakTime);
}
public void Update(Playfield playfield)
{
Coverage.Value = isBreakTime.Value
? 0
: Math.Min(MAX_COVERAGE, MIN_COVERAGE + combo.Value * coverage_increase_per_combo) / reference_playfield_height;
}
protected override PlayfieldCoveringWrapper CreateCover(Drawable content) => new LegacyPlayfieldCover(content);
private partial class LegacyPlayfieldCover : PlayfieldCoveringWrapper
{
[Resolved]
private ISkinSource skin { get; set; } = null!;
private IBindable<float>? hitPosition;
public LegacyPlayfieldCover(Drawable content)
: base(content)
{
}
protected override void LoadComplete()
{
base.LoadComplete();
skin.SourceChanged += onSkinChanged;
onSkinChanged();
}
private void onSkinChanged()
{
hitPosition = skin.GetManiaSkinConfig<float>(LegacyManiaSkinConfigurationLookups.HitPosition);
}
protected override float GetHeight(float coverage)
{
// In osu!stable, the cover is applied in absolute (x768) coordinates from the hit position.
float availablePlayfieldHeight = Math.Abs(reference_playfield_height - (hitPosition?.Value ?? Stage.HIT_TARGET_POSITION));
if (availablePlayfieldHeight == 0)
return base.GetHeight(coverage);
return base.GetHeight(coverage) * reference_playfield_height / availablePlayfieldHeight;
}
}
}
}

View File

@ -6,7 +6,6 @@ using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
@ -15,7 +14,7 @@ using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mania.Mods
{
public abstract class ManiaModPlayfieldCover : ModHidden, IApplicableToDrawableRuleset<ManiaHitObject>
public abstract class ManiaModWithPlayfieldCover : ModHidden, IApplicableToDrawableRuleset<ManiaHitObject>
{
public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight<ManiaHitObject>) };
@ -24,7 +23,9 @@ namespace osu.Game.Rulesets.Mania.Mods
/// </summary>
protected abstract CoverExpandDirection ExpandDirection { get; }
[SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")]
/// <summary>
/// The relative area that should be completely covered. This does not include the fade.
/// </summary>
public abstract BindableNumber<float> Coverage { get; }
public virtual void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
@ -37,15 +38,17 @@ namespace osu.Game.Rulesets.Mania.Mods
Container hocParent = (Container)hoc.Parent!;
hocParent.Remove(hoc, false);
hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c =>
hocParent.Add(CreateCover(hoc).With(c =>
{
c.RelativeSizeAxes = Axes.Both;
c.Direction = ExpandDirection;
c.Coverage = Coverage.Value;
c.Coverage.BindTo(Coverage);
}));
}
}
protected virtual PlayfieldCoveringWrapper CreateCover(Drawable content) => new PlayfieldCoveringWrapper(content);
protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
}

View File

@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.ComponentModel;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -8,17 +10,24 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Utils;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
using osuTK.Graphics;
using Container = osu.Framework.Graphics.Containers.Container;
namespace osu.Game.Rulesets.Mania.UI
{
/// <summary>
/// A <see cref="Container"/> that has its contents partially hidden by an adjustable "cover". This is intended to be used in a playfield.
/// A <see cref="Framework.Graphics.Containers.Container"/> that has its contents partially hidden by an adjustable "cover". This is intended to be used in a playfield.
/// </summary>
public partial class PlayfieldCoveringWrapper : CompositeDrawable
{
/// <summary>
/// The relative area that should be completely covered. This does not include the fade.
/// </summary>
public readonly BindableFloat Coverage = new BindableFloat();
/// <summary>
/// The complete cover, including gradient and fill.
/// </summary>
@ -36,6 +45,8 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly IBindable<ScrollingDirection> scrollDirection = new Bindable<ScrollingDirection>();
private float currentCoverageHeight;
public PlayfieldCoveringWrapper(Drawable content)
{
InternalChild = new BufferedContainer
@ -94,21 +105,46 @@ namespace osu.Game.Rulesets.Mania.UI
scrollDirection.BindValueChanged(onScrollDirectionChanged, true);
}
protected override void LoadComplete()
{
base.LoadComplete();
updateCoverSize(true);
}
protected override void Update()
{
base.Update();
updateCoverSize(false);
}
private void updateCoverSize(bool instant)
{
float targetCoverage;
float targetAlpha;
if (instant)
{
targetCoverage = Coverage.Value;
targetAlpha = Coverage.Value > 0 ? 1 : 0;
}
else
{
targetCoverage = (float)Interpolation.DampContinuously(currentCoverageHeight, Coverage.Value, 25, Math.Abs(Time.Elapsed));
targetAlpha = (float)Interpolation.DampContinuously(gradient.Alpha, Coverage.Value > 0 ? 1 : 0, 25, Math.Abs(Time.Elapsed));
}
filled.Height = GetHeight(targetCoverage);
gradient.Y = -GetHeight(targetCoverage);
gradient.Alpha = targetAlpha;
currentCoverageHeight = targetCoverage;
}
protected virtual float GetHeight(float coverage) => coverage;
private void onScrollDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
=> cover.Rotation = direction.NewValue == ScrollingDirection.Up ? 0 : 180f;
/// <summary>
/// The relative area that should be completely covered. This does not include the fade.
/// </summary>
public float Coverage
{
set
{
filled.Height = value;
gradient.Y = -value;
}
}
/// <summary>
/// The direction in which the cover expands.
/// </summary>
@ -123,11 +159,13 @@ namespace osu.Game.Rulesets.Mania.UI
/// <summary>
/// The cover expands along the scrolling direction.
/// </summary>
[Description("Along scroll")]
AlongScroll,
/// <summary>
/// The cover expands against the scrolling direction.
/// </summary>
[Description("Against scroll")]
AgainstScroll
}
}

View File

@ -40,7 +40,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double aimRatingNoSliders = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier;
double speedRating = Math.Sqrt(skills[2].DifficultyValue()) * difficulty_multiplier;
double speedNotes = ((Speed)skills[2]).RelevantNoteCount();
double flashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * difficulty_multiplier;
double flashlightRating = 0.0;
if (mods.Any(h => h is OsuModFlashlight))
flashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * difficulty_multiplier;
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
@ -126,13 +130,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
return new Skill[]
var skills = new List<Skill>
{
new Aim(mods, true),
new Aim(mods, false),
new Speed(mods),
new Flashlight(mods)
new Speed(mods)
};
if (mods.Any(h => h is OsuModFlashlight))
skills.Add(new Flashlight(mods));
return skills.ToArray();
}
protected override Mod[] DifficultyAdjustmentMods => new Mod[]

View File

@ -1,4 +1,4 @@
// 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.
using System;
@ -38,12 +38,18 @@ namespace osu.Game.Rulesets.Osu.Mods
private ReplayState<OsuAction> state = null!;
private double lastStateChangeTime;
private DrawableOsuRuleset ruleset = null!;
private IPressHandler pressHandler = null!;
private bool hasReplay;
private bool legacyReplay;
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
{
ruleset = (DrawableOsuRuleset)drawableRuleset;
// grab the input manager for future use.
osuInputManager = ((DrawableOsuRuleset)drawableRuleset).KeyBindingInputManager;
osuInputManager = ruleset.KeyBindingInputManager;
}
public void ApplyToPlayer(Player player)
@ -51,15 +57,22 @@ namespace osu.Game.Rulesets.Osu.Mods
if (osuInputManager.ReplayInputHandler != null)
{
hasReplay = true;
Debug.Assert(ruleset.ReplayScore != null);
legacyReplay = ruleset.ReplayScore.ScoreInfo.IsLegacyScore;
pressHandler = legacyReplay ? new LegacyReplayPressHandler(this) : new PressHandler(this);
return;
}
pressHandler = new PressHandler(this);
osuInputManager.AllowGameplayInputs = false;
}
public void Update(Playfield playfield)
{
if (hasReplay)
if (hasReplay && !legacyReplay)
return;
bool requiresHold = false;
@ -132,11 +145,62 @@ namespace osu.Game.Rulesets.Osu.Mods
if (down)
{
state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton);
pressHandler.HandlePress(wasLeft);
wasLeft = !wasLeft;
}
else
{
pressHandler.HandleRelease(wasLeft);
}
}
}
state.Apply(osuInputManager.CurrentState, osuInputManager);
private interface IPressHandler
{
void HandlePress(bool wasLeft);
void HandleRelease(bool wasLeft);
}
private class PressHandler : IPressHandler
{
private readonly OsuModRelax mod;
public PressHandler(OsuModRelax mod)
{
this.mod = mod;
}
public void HandlePress(bool wasLeft)
{
mod.state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton);
mod.state.Apply(mod.osuInputManager.CurrentState, mod.osuInputManager);
}
public void HandleRelease(bool wasLeft)
{
mod.state.Apply(mod.osuInputManager.CurrentState, mod.osuInputManager);
}
}
// legacy replays do not contain key-presses with Relax mod, so they need to be triggered by themselves.
private class LegacyReplayPressHandler : IPressHandler
{
private readonly OsuModRelax mod;
public LegacyReplayPressHandler(OsuModRelax mod)
{
this.mod = mod;
}
public void HandlePress(bool wasLeft)
{
mod.osuInputManager.KeyBindingContainer.TriggerPressed(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton);
}
public void HandleRelease(bool wasLeft)
{
// this intentionally releases right when `wasLeft` is true because `wasLeft` is set at point of press and not at point of release
mod.osuInputManager.KeyBindingContainer.TriggerReleased(wasLeft ? OsuAction.RightButton : OsuAction.LeftButton);
}
}
}

View File

@ -31,4 +31,22 @@
<PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Moq" Version="4.17.2" />
</ItemGroup>
<!-- osu.Framework.Android depends on https://www.nuget.org/packages/Xamarin.AndroidX.Window,
which - via a chain of transitive dependencies - also includes https://www.nuget.org/packages/Xamarin.Jetbrains.Annotations,
which causes compile failures such as:
The type 'NotNullAttribute' exists in both 'JetBrains.Annotations, Version=4242.42.42.42, Culture=neutral, PublicKeyToken=1010a0d8d6380325'
and 'Xamarin.Jetbrains.Annotations, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' [D:\a\osu\osu\osu.Game.Tests.Android\osu.Game.Tests.Android.csproj]
We cannot easily change the source files, because of how this project works
(all source files are basically symlinked from the desktop test project,
so changing anything there just for the sake of mobile would be strange).
Thus, apply the following "interesting" workaround as borrowed from https://stackoverflow.com/a/65127159 instead. -->
<Target Name="AddPackageAliases" BeforeTargets="FindReferenceAssembliesForReferences;ResolveReferences">
<ItemGroup>
<ReferencePath Condition="%(Filename) == 'Xamarin.Jetbrains.Annotations'">
<Aliases>XamarinJetbrainsAnnotations</Aliases>
</ReferencePath>
</ItemGroup>
</Target>
</Project>

View File

@ -28,7 +28,12 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestLocalCacheQueriedFirst()
{
var localLookupResult = new OnlineBeatmapMetadata { BeatmapID = 123456, BeatmapStatus = BeatmapOnlineStatus.Ranked };
var localLookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 123456,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
};
localCachedMetadataSourceMock.Setup(src => src.Available).Returns(true);
localCachedMetadataSourceMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out localLookupResult))
.Returns(true);
@ -42,6 +47,7 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch: false);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
localCachedMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata>.IsAny!), Times.Once);
apiMetadataSourceMock.Verify(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out It.Ref<OnlineBeatmapMetadata>.IsAny!), Times.Never);
}
@ -54,7 +60,12 @@ namespace osu.Game.Tests.Beatmaps
localCachedMetadataSourceMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out localLookupResult))
.Returns(false);
var onlineLookupResult = new OnlineBeatmapMetadata { BeatmapID = 123456, BeatmapStatus = BeatmapOnlineStatus.Ranked };
var onlineLookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 123456,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
};
apiMetadataSourceMock.Setup(src => src.Available).Returns(true);
apiMetadataSourceMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out onlineLookupResult))
.Returns(true);
@ -66,6 +77,7 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch: false);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
localCachedMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Once);
apiMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Once);
}
@ -73,12 +85,22 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestPreferOnlineFetch()
{
var localLookupResult = new OnlineBeatmapMetadata { BeatmapID = 123456, BeatmapStatus = BeatmapOnlineStatus.Ranked };
var localLookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 123456,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
};
localCachedMetadataSourceMock.Setup(src => src.Available).Returns(true);
localCachedMetadataSourceMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out localLookupResult))
.Returns(true);
var onlineLookupResult = new OnlineBeatmapMetadata { BeatmapID = 123456, BeatmapStatus = BeatmapOnlineStatus.Graveyard };
var onlineLookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 123456,
BeatmapStatus = BeatmapOnlineStatus.Graveyard,
BeatmapSetStatus = BeatmapOnlineStatus.Graveyard,
};
apiMetadataSourceMock.Setup(src => src.Available).Returns(true);
apiMetadataSourceMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out onlineLookupResult))
.Returns(true);
@ -90,6 +112,7 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch: true);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Graveyard));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.Graveyard));
localCachedMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Never);
apiMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Once);
}
@ -97,7 +120,12 @@ namespace osu.Game.Tests.Beatmaps
[Test]
public void TestPreferOnlineFetchFallsBackToLocalCacheIfOnlineSourceUnavailable()
{
var localLookupResult = new OnlineBeatmapMetadata { BeatmapID = 123456, BeatmapStatus = BeatmapOnlineStatus.Ranked };
var localLookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 123456,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
};
localCachedMetadataSourceMock.Setup(src => src.Available).Returns(true);
localCachedMetadataSourceMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out localLookupResult))
.Returns(true);
@ -111,6 +139,7 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch: true);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
localCachedMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Once);
apiMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Never);
}
@ -135,6 +164,7 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch: false);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmap.OnlineID, Is.EqualTo(-1));
localCachedMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Once);
apiMetadataSourceMock.Verify(src => src.TryLookup(beatmap, out It.Ref<OnlineBeatmapMetadata?>.IsAny!), Times.Once);
@ -163,6 +193,7 @@ namespace osu.Game.Tests.Beatmaps
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmap.OnlineID, Is.EqualTo(123456));
}
@ -193,5 +224,217 @@ namespace osu.Game.Tests.Beatmaps
Assert.That(beatmap.OnlineID, Is.EqualTo(123456));
}
[Test]
public void TestReturnedMetadataHasDifferentOnlineID([Values] bool preferOnlineFetch)
{
var lookupResult = new OnlineBeatmapMetadata { BeatmapID = 654321, BeatmapStatus = BeatmapOnlineStatus.Ranked };
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out lookupResult))
.Returns(true);
var beatmap = new BeatmapInfo { OnlineID = 123456 };
var beatmapSet = new BeatmapSetInfo(beatmap.Yield());
beatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmap.OnlineID, Is.EqualTo(-1));
}
[Test]
public void TestMetadataLookupForBeatmapWithoutPopulatedIDAndCorrectHash([Values] bool preferOnlineFetch)
{
var lookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"deadbeef",
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out lookupResult))
.Returns(true);
var beatmap = new BeatmapInfo
{
MD5Hash = @"deadbeef"
};
var beatmapSet = new BeatmapSetInfo(beatmap.Yield());
beatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(beatmap.OnlineID, Is.EqualTo(654321));
}
[Test]
public void TestMetadataLookupForBeatmapWithoutPopulatedIDAndIncorrectHash([Values] bool preferOnlineFetch)
{
var lookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"cafebabe",
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out lookupResult))
.Returns(true);
var beatmap = new BeatmapInfo
{
MD5Hash = @"deadbeef"
};
var beatmapSet = new BeatmapSetInfo(beatmap.Yield());
beatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmap.OnlineID, Is.EqualTo(-1));
}
[Test]
public void TestReturnedMetadataHasDifferentHash([Values] bool preferOnlineFetch)
{
var lookupResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"deadbeef"
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.IsAny<BeatmapInfo>(), out lookupResult))
.Returns(true);
var beatmap = new BeatmapInfo
{
OnlineID = 654321,
MD5Hash = @"cafebabe",
};
var beatmapSet = new BeatmapSetInfo(beatmap.Yield());
beatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(beatmap.OnlineID, Is.EqualTo(654321));
}
[Test]
public void TestPartiallyModifiedSet([Values] bool preferOnlineFetch)
{
var firstResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"cafebabe"
};
var secondResult = new OnlineBeatmapMetadata
{
BeatmapID = 666666,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"dededede"
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.Is<BeatmapInfo>(bi => bi.OnlineID == 654321), out firstResult))
.Returns(true);
targetMock.Setup(src => src.TryLookup(It.Is<BeatmapInfo>(bi => bi.OnlineID == 666666), out secondResult))
.Returns(true);
var firstBeatmap = new BeatmapInfo
{
OnlineID = 654321,
MD5Hash = @"cafebabe",
};
var secondBeatmap = new BeatmapInfo
{
OnlineID = 666666,
MD5Hash = @"deadbeef"
};
var beatmapSet = new BeatmapSetInfo(new[]
{
firstBeatmap,
secondBeatmap
});
firstBeatmap.BeatmapSet = beatmapSet;
secondBeatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(firstBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(firstBeatmap.OnlineID, Is.EqualTo(654321));
Assert.That(secondBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(secondBeatmap.OnlineID, Is.EqualTo(666666));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
}
[Test]
public void TestPartiallyMaliciousSet([Values] bool preferOnlineFetch)
{
var firstResult = new OnlineBeatmapMetadata
{
BeatmapID = 654321,
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"cafebabe"
};
var secondResult = new OnlineBeatmapMetadata
{
BeatmapStatus = BeatmapOnlineStatus.Ranked,
BeatmapSetStatus = BeatmapOnlineStatus.Ranked,
MD5Hash = @"dededede"
};
var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock;
targetMock.Setup(src => src.Available).Returns(true);
targetMock.Setup(src => src.TryLookup(It.Is<BeatmapInfo>(bi => bi.OnlineID == 654321), out firstResult))
.Returns(true);
targetMock.Setup(src => src.TryLookup(It.Is<BeatmapInfo>(bi => bi.OnlineID == 666666), out secondResult))
.Returns(true);
var firstBeatmap = new BeatmapInfo
{
OnlineID = 654321,
MD5Hash = @"cafebabe",
};
var secondBeatmap = new BeatmapInfo
{
OnlineID = 666666,
MD5Hash = @"deadbeef"
};
var beatmapSet = new BeatmapSetInfo(new[]
{
firstBeatmap,
secondBeatmap
});
firstBeatmap.BeatmapSet = beatmapSet;
secondBeatmap.BeatmapSet = beatmapSet;
metadataLookup.Update(beatmapSet, preferOnlineFetch);
Assert.That(firstBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked));
Assert.That(firstBeatmap.OnlineID, Is.EqualTo(654321));
Assert.That(secondBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None));
Assert.That(secondBeatmap.OnlineID, Is.EqualTo(-1));
Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None));
}
}
}

View File

@ -274,10 +274,12 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.IsTrue(filterCriteria.OnlineStatus.IsUpperInclusive);
}
[Test]
public void TestApplyCreatorQueries()
[TestCase("creator")]
[TestCase("author")]
[TestCase("mapper")]
public void TestApplyCreatorQueries(string keyword)
{
const string query = "beatmap specifically by creator=my_fav";
string query = $"beatmap specifically by {keyword}=my_fav";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim());
@ -452,5 +454,111 @@ namespace osu.Game.Tests.NonVisual.Filtering
return false;
}
}
private static readonly object[] correct_date_query_examples =
{
new object[] { "600" },
new object[] { "0.5s" },
new object[] { "120m" },
new object[] { "48h120s" },
new object[] { "10y24M" },
new object[] { "10y60d120s" },
new object[] { "0y0M2d" },
new object[] { "1y1M2d" }
};
[Test]
[TestCaseSource(nameof(correct_date_query_examples))]
public void TestValidDateQueries(string dateQuery)
{
string query = $"played<{dateQuery} time";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
}
private static readonly object[] incorrect_date_query_examples =
{
new object[] { ".5s" },
new object[] { "7m27" },
new object[] { "7m7m7m" },
new object[] { "5s6m" },
new object[] { "7d7y" },
new object[] { "0:3:6" },
new object[] { "0:3:" },
new object[] { "\"three days\"" },
new object[] { "0.1y0.1M2d" },
new object[] { "0.99y0.99M2d" },
new object[] { string.Empty }
};
[Test]
[TestCaseSource(nameof(incorrect_date_query_examples))]
public void TestInvalidDateQueries(string dateQuery)
{
string query = $"played<{dateQuery} time";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter);
}
[Test]
public void TestGreaterDateQuery()
{
const string query = "played>50";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null);
Assert.That(filterCriteria.LastPlayed.Min, Is.Null);
// the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
// (irrelevant in proportion to the actual filter proscribed).
Assert.That(filterCriteria.LastPlayed.Max, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void TestLowerDateQuery()
{
const string query = "played<50";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.LastPlayed.Max, Is.Null);
Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null);
// the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
// (irrelevant in proportion to the actual filter proscribed).
Assert.That(filterCriteria.LastPlayed.Min, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void TestBothSidesDateQuery()
{
const string query = "played>3M played<1y6M";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null);
Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null);
// the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
// (irrelevant in proportion to the actual filter proscribed).
Assert.That(filterCriteria.LastPlayed.Min, Is.EqualTo(DateTimeOffset.Now.AddYears(-1).AddMonths(-6)).Within(TimeSpan.FromSeconds(5)));
Assert.That(filterCriteria.LastPlayed.Max, Is.EqualTo(DateTimeOffset.Now.AddMonths(-3)).Within(TimeSpan.FromSeconds(5)));
}
[Test]
public void TestEqualDateQuery()
{
const string query = "played=50";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter);
}
[Test]
public void TestOutOfRangeDateQuery()
{
const string query = "played<10000y";
var filterCriteria = new FilterCriteria();
FilterQueryParser.ApplyQueries(filterCriteria, query);
Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min);
}
}
}

View File

@ -43,13 +43,13 @@ namespace osu.Game.Tests.Rulesets
AddStep("setup provider", () =>
{
var rulesetSkinProvider = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin);
rulesetSkinProvider.Add(requester = new SkinRequester());
requester = new SkinRequester();
requester.OnLoadAsync += () => textureOnLoad = requester.GetTexture("test-image");
Child = rulesetSkinProvider;
Child = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin)
{
Child = requester
};
});
AddAssert("requester got correct initial texture", () => textureOnLoad != null);

View File

@ -2,12 +2,17 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.Solo;
using osu.Game.Overlays.Toolbar;
using osu.Game.Scoring;
using osu.Game.Users;
using osuTK;
using osuTK.Graphics;
@ -87,5 +92,91 @@ namespace osu.Game.Tests.Visual.Menus
AddStep($"Change state to {state}", () => dummyAPI.SetState(state));
}
}
[Test]
public void TestTransientUserStatisticsDisplay()
{
AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Gain", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 123_456,
PP = 1234
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Loss", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = 123_456,
PP = 1234
});
});
AddStep("No change", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Was null", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = null,
PP = null
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Became null", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = null,
PP = null
});
});
}
}
}

View File

@ -986,6 +986,29 @@ namespace osu.Game.Tests.Visual.Navigation
}
}
[Test]
public void TestPresentBeatmapAfterDeletion()
{
BeatmapSetInfo beatmap = null;
Screens.Select.SongSelect songSelect = null;
PushAndConfirm(() => songSelect = new TestPlaySongSelect());
AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded);
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("delete selected beatmap", () =>
{
beatmap = Game.Beatmap.Value.BeatmapSetInfo;
Game.BeatmapManager.Delete(Game.Beatmap.Value.BeatmapSetInfo);
});
AddUntilStep("nothing selected", () => Game.Beatmap.IsDefault);
AddStep("present deleted beatmap", () => Game.PresentBeatmap(beatmap));
AddAssert("still nothing selected", () => Game.Beatmap.IsDefault);
}
private Func<Player> playToResults()
{
var player = playToCompletion();

View File

@ -170,6 +170,24 @@ namespace osu.Game.Tests.Visual.Online
});
}
[Test]
public void TestPostAsOwner()
{
setUpCommentsResponse(getExampleComments());
AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123));
setUpPostResponse(true);
AddStep("enter text", () => editorTextBox.Current.Value = "comm");
AddStep("submit", () => commentsContainer.ChildrenOfType<CommentEditor>().Single().ChildrenOfType<RoundedButton>().First().TriggerClick());
AddUntilStep("comment sent", () =>
{
string writtenText = editorTextBox.Current.Value;
var comment = commentsContainer.ChildrenOfType<DrawableComment>().LastOrDefault();
return comment != null && comment.ChildrenOfType<SpriteText>().Any(y => y.Text == writtenText) && comment.ChildrenOfType<SpriteText>().Any(y => y.Text == "MAPPER");
});
}
private void setUpCommentsResponse(CommentBundle commentBundle)
=> AddStep("set up response", () =>
{
@ -183,7 +201,7 @@ namespace osu.Game.Tests.Visual.Online
};
});
private void setUpPostResponse()
private void setUpPostResponse(bool asOwner = false)
=> AddStep("set up response", () =>
{
dummyAPI.HandleRequest = request =>
@ -191,7 +209,7 @@ namespace osu.Game.Tests.Visual.Online
if (!(request is CommentPostRequest req))
return false;
req.TriggerSuccess(new CommentBundle
var bundle = new CommentBundle
{
Comments = new List<Comment>
{
@ -202,9 +220,26 @@ namespace osu.Game.Tests.Visual.Online
LegacyName = "FirstUser",
CreatedAt = DateTimeOffset.Now,
VotesCount = 98,
CommentableId = 2001,
CommentableType = "test",
}
}
});
};
if (asOwner)
{
bundle.Comments[0].UserId = 1001;
bundle.Comments[0].User = new APIUser { Id = 1001, Username = "FirstUser" };
bundle.CommentableMeta.Add(new CommentableMeta
{
Id = 2001,
OwnerId = 1001,
OwnerTitle = "MAPPER",
Type = "test",
});
}
req.TriggerSuccess(bundle);
return true;
};
});

View File

@ -4,62 +4,66 @@
#nullable disable
using System;
using NUnit.Framework;
using osu.Framework.Allocation;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Comments;
using osu.Game.Tests.Visual.UserInterface;
namespace osu.Game.Tests.Visual.Online
{
public partial class TestSceneDrawableComment : OsuTestScene
public partial class TestSceneDrawableComment : ThemeComparisonTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
private Container container;
[SetUp]
public void SetUp() => Schedule(() =>
public TestSceneDrawableComment()
: base(false)
{
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4,
},
container = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
};
});
[TestCaseSource(nameof(comments))]
public void TestComment(string description, string text)
{
AddStep(description, () =>
{
comment.Pinned = description == "Pinned";
comment.Message = text;
container.Add(new DrawableComment(comment));
});
}
private static readonly Comment comment = new Comment
protected override Drawable CreateContent() => new OsuScrollContainer(Direction.Vertical)
{
Id = 1,
LegacyName = "Test User",
CreatedAt = DateTimeOffset.Now,
VotesCount = 0,
RelativeSizeAxes = Axes.Both,
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
ChildrenEnumerable = comments.Select(info =>
{
var comment = new Comment
{
Id = 1,
UserId = 1000,
User = new APIUser { Id = 1000, Username = "Someone" },
CreatedAt = DateTimeOffset.Now,
VotesCount = 0,
Pinned = info[0] == "Pinned",
Message = info[1],
CommentableId = 2001,
CommentableType = "test"
};
return new[]
{
new DrawableComment(comment, Array.Empty<CommentableMeta>()),
new DrawableComment(comment, new[]
{
new CommentableMeta
{
Id = 2001,
OwnerId = comment.UserId,
OwnerTitle = "MAPPER",
Type = "test",
},
new CommentableMeta { Title = "Other Meta" },
}),
};
}).SelectMany(c => c)
}
};
private static object[] comments =
private static readonly string[][] comments =
{
new[] { "Plain", "This is plain comment" },
new[] { "Pinned", "This is pinned comment" },

View File

@ -154,6 +154,19 @@ namespace osu.Game.Tests.Visual.Online
});
}
[Test]
public void TestUnrankedPP()
{
AddStep("Load scores with unranked PP", () =>
{
var allScores = createScores();
allScores.Scores[0].Ranked = false;
allScores.UserScore = createUserBest();
allScores.UserScore.Score.Ranked = false;
scoresContainer.Scores = allScores;
});
}
private ulong onlineID = 1;
private APIScoresCollection createScores()
@ -184,6 +197,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 1234567890,
Accuracy = 1,
Ranked = true,
},
new SoloScoreInfo
{
@ -206,6 +220,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 1234789,
Accuracy = 0.9997,
Ranked = true,
},
new SoloScoreInfo
{
@ -227,6 +242,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 12345678,
Accuracy = 0.9854,
Ranked = true,
},
new SoloScoreInfo
{
@ -247,6 +263,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 1234567,
Accuracy = 0.8765,
Ranked = true,
},
new SoloScoreInfo
{
@ -263,6 +280,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 123456,
Accuracy = 0.6543,
Ranked = true,
},
}
};
@ -309,6 +327,7 @@ namespace osu.Game.Tests.Visual.Online
MaxCombo = 1234,
TotalScore = 123456,
Accuracy = 0.6543,
Ranked = true,
},
Position = 1337,
};

View File

@ -35,8 +35,6 @@ namespace osu.Game.Tests.Visual.Online
private Action<GetUsersRequest>? handleGetUsersRequest;
private Action<GetUserRequest>? handleGetUserRequest;
private IDisposable? subscription;
private readonly Dictionary<(int userId, string rulesetName), UserStatistics> serverSideStatistics = new Dictionary<(int userId, string rulesetName), UserStatistics>();
[SetUpSteps]
@ -252,26 +250,6 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(6_000_000));
}
[Test]
public void TestStatisticsUpdateNotFiredAfterSubscriptionDisposal()
{
int userId = getUserId();
setUpUser(userId);
long scoreId = getScoreId();
var ruleset = new OsuRuleset().RulesetInfo;
SoloStatisticsUpdate? update = null;
registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate);
AddStep("unsubscribe", () => subscription!.Dispose());
feignScoreProcessing(userId, ruleset, 5_000_000);
AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId));
AddWaitStep("wait a bit", 5);
AddAssert("update not received", () => update == null);
}
[Test]
public void TestGlobalStatisticsUpdatedAfterRegistrationAddedAndScoreProcessed()
{
@ -312,13 +290,20 @@ namespace osu.Game.Tests.Visual.Online
}
private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action<SoloStatisticsUpdate> onUpdateReady) =>
AddStep("register for updates", () => subscription = watcher.RegisterForStatisticsUpdateAfter(
new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser())
AddStep("register for updates", () =>
{
watcher.RegisterForStatisticsUpdateAfter(
new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser())
{
Ruleset = rulesetInfo,
OnlineID = scoreId
});
watcher.LatestUpdate.BindValueChanged(update =>
{
Ruleset = rulesetInfo,
OnlineID = scoreId
},
onUpdateReady));
if (update.NewValue?.Score.OnlineID == scoreId)
onUpdateReady.Invoke(update.NewValue);
});
});
private void feignScoreProcessing(int userId, RulesetInfo rulesetInfo, long newTotalScore)
=> AddStep("feign score processing", () => serverSideStatistics[(userId, rulesetInfo.ShortName)] = new UserStatistics { TotalScore = newTotalScore });

View File

@ -40,7 +40,8 @@ namespace osu.Game.Tests.Visual.Online
new APIMod { Acronym = new OsuModHardRock().Acronym },
new APIMod { Acronym = new OsuModDoubleTime().Acronym },
},
Accuracy = 0.9813
Accuracy = 0.9813,
Ranked = true,
};
var secondScore = new SoloScoreInfo
@ -62,7 +63,8 @@ namespace osu.Game.Tests.Visual.Online
new APIMod { Acronym = new OsuModHardRock().Acronym },
new APIMod { Acronym = new OsuModDoubleTime().Acronym },
},
Accuracy = 0.998546
Accuracy = 0.998546,
Ranked = true,
};
var thirdScore = new SoloScoreInfo
@ -79,7 +81,8 @@ namespace osu.Game.Tests.Visual.Online
DifficultyName = "Insane"
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.9726
Accuracy = 0.9726,
Ranked = true,
};
var noPPScore = new SoloScoreInfo
@ -95,7 +98,26 @@ namespace osu.Game.Tests.Visual.Online
DifficultyName = "[4K] Cataclysmic Hypernova"
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879
Accuracy = 0.55879,
Ranked = true,
};
var lovedScore = new SoloScoreInfo
{
Rank = ScoreRank.B,
Beatmap = new APIBeatmap
{
BeatmapSet = new APIBeatmapSet
{
Title = "C18H27NO3(extend)",
Artist = "Team Grimoire",
},
DifficultyName = "[4K] Cataclysmic Hypernova",
Status = BeatmapOnlineStatus.Loved,
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879,
Ranked = true,
};
var unprocessedPPScore = new SoloScoreInfo
@ -112,7 +134,26 @@ namespace osu.Game.Tests.Visual.Online
Status = BeatmapOnlineStatus.Ranked,
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879
Accuracy = 0.55879,
Ranked = true,
};
var unrankedPPScore = new SoloScoreInfo
{
Rank = ScoreRank.B,
Beatmap = new APIBeatmap
{
BeatmapSet = new APIBeatmapSet
{
Title = "C18H27NO3(extend)",
Artist = "Team Grimoire",
},
DifficultyName = "[4K] Cataclysmic Hypernova",
Status = BeatmapOnlineStatus.Ranked,
},
EndedAt = DateTimeOffset.Now,
Accuracy = 0.55879,
Ranked = false,
};
Add(new FillFlowContainer
@ -128,7 +169,9 @@ namespace osu.Game.Tests.Visual.Online
new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(firstScore)),
new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(secondScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(noPPScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(lovedScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unprocessedPPScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unrankedPPScore)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(firstScore, 0.97)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)),
new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)),

View File

@ -9,7 +9,6 @@ using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Input.States;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
@ -18,13 +17,22 @@ namespace osu.Game.Tests.Visual.UserInterface
public partial class TestSceneOsuDropdown : ThemeComparisonTestScene
{
protected override Drawable CreateContent() =>
new OsuEnumDropdown<BeatmapOnlineStatus>
new OsuEnumDropdown<TestEnum>
{
Anchor = Anchor.Centre,
Origin = Anchor.TopCentre,
Width = 150
};
private enum TestEnum
{
[System.ComponentModel.Description("Option")]
Option,
[System.ComponentModel.Description("Really lonnnnnnng option")]
ReallyLongOption,
}
[Test]
// todo: this can be written much better if ThemeComparisonTestScene has a manual input manager
public void TestBackAction()
@ -43,7 +51,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent<GlobalAction>(new InputState(), GlobalAction.Back)));
AddAssert("closed", () => dropdown().ChildrenOfType<Menu>().Single().State == MenuState.Closed);
OsuEnumDropdown<BeatmapOnlineStatus> dropdown() => this.ChildrenOfType<OsuEnumDropdown<BeatmapOnlineStatus>>().First();
OsuEnumDropdown<TestEnum> dropdown() => this.ChildrenOfType<OsuEnumDropdown<TestEnum>>().First();
}
}
}

View File

@ -14,31 +14,39 @@ namespace osu.Game.Tests.Visual.UserInterface
{
public abstract partial class ThemeComparisonTestScene : OsuGridTestScene
{
protected ThemeComparisonTestScene()
: base(1, 2)
private readonly bool showWithoutColourProvider;
protected ThemeComparisonTestScene(bool showWithoutColourProvider = true)
: base(1, showWithoutColourProvider ? 2 : 1)
{
this.showWithoutColourProvider = showWithoutColourProvider;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Cell(0, 0).AddRange(new[]
if (showWithoutColourProvider)
{
new Box
Cell(0, 0).AddRange(new[]
{
RelativeSizeAxes = Axes.Both,
Colour = colours.GreySeaFoam
},
CreateContent()
});
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colours.GreySeaFoam
},
CreateContent()
});
}
}
protected void CreateThemedContent(OverlayColourScheme colourScheme)
{
var colourProvider = new OverlayColourProvider(colourScheme);
Cell(0, 1).Clear();
Cell(0, 1).Add(new DependencyProvidingContainer
int col = showWithoutColourProvider ? 1 : 0;
Cell(0, col).Clear();
Cell(0, col).Add(new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = new (Type, object)[]

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Platform;
@ -38,17 +39,22 @@ namespace osu.Game.Beatmaps
/// <param name="preferOnlineFetch">Whether metadata from an online source should be preferred. If <c>true</c>, the local cache will be skipped to ensure the freshest data state possible.</param>
public void Update(BeatmapSetInfo beatmapSet, bool preferOnlineFetch)
{
var lookupResults = new List<OnlineBeatmapMetadata?>();
foreach (var beatmapInfo in beatmapSet.Beatmaps)
{
if (!tryLookup(beatmapInfo, preferOnlineFetch, out var res))
continue;
if (res == null)
if (res == null || shouldDiscardLookupResult(res, beatmapInfo))
{
beatmapInfo.ResetOnlineInfo();
lookupResults.Add(null); // mark lookup failure
continue;
}
lookupResults.Add(res);
beatmapInfo.OnlineID = res.BeatmapID;
beatmapInfo.OnlineMD5Hash = res.MD5Hash;
beatmapInfo.LastOnlineUpdate = res.LastUpdated;
@ -57,19 +63,34 @@ namespace osu.Game.Beatmaps
beatmapInfo.BeatmapSet.OnlineID = res.BeatmapSetID;
// Some metadata should only be applied if there's no local changes.
if (shouldSaveOnlineMetadata(beatmapInfo))
if (beatmapInfo.MatchesOnlineVersion)
{
beatmapInfo.Status = res.BeatmapStatus;
beatmapInfo.Metadata.Author.OnlineID = res.AuthorID;
}
if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata))
{
beatmapInfo.BeatmapSet.Status = res.BeatmapSetStatus ?? BeatmapOnlineStatus.None;
beatmapInfo.BeatmapSet.DateRanked = res.DateRanked;
beatmapInfo.BeatmapSet.DateSubmitted = res.DateSubmitted;
}
}
if (beatmapSet.Beatmaps.All(b => b.MatchesOnlineVersion)
&& lookupResults.All(r => r != null)
&& lookupResults.Select(r => r!.BeatmapSetID).Distinct().Count() == 1)
{
var representative = lookupResults.First()!;
beatmapSet.Status = representative.BeatmapSetStatus ?? BeatmapOnlineStatus.None;
beatmapSet.DateRanked = representative.DateRanked;
beatmapSet.DateSubmitted = representative.DateSubmitted;
}
}
private bool shouldDiscardLookupResult(OnlineBeatmapMetadata result, BeatmapInfo beatmapInfo)
{
if (beatmapInfo.OnlineID > 0 && result.BeatmapID != beatmapInfo.OnlineID)
return true;
if (beatmapInfo.OnlineID == -1 && result.MD5Hash != beatmapInfo.MD5Hash)
return true;
return false;
}
/// <summary>
@ -104,12 +125,6 @@ namespace osu.Game.Beatmaps
return false;
}
/// <summary>
/// Check whether the provided beatmap is in a state where online "ranked" status metadata should be saved against it.
/// Handles the case where a user may have locally modified a beatmap in the editor and expects the local status to stick.
/// </summary>
private static bool shouldSaveOnlineMetadata(BeatmapInfo beatmapInfo) => beatmapInfo.MatchesOnlineVersion || beatmapInfo.Status != BeatmapOnlineStatus.LocallyModified;
public void Dispose()
{
apiMetadataSource.Dispose();

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.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.Sprites
{
/// <summary>
/// A <see cref="SpriteIcon"/> with a publicly settable tooltip text.
/// </summary>
public partial class SpriteIconWithTooltip : SpriteIcon, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.Sprites
{
/// <summary>
/// An <see cref="OsuSpriteText"/> with a publicly settable tooltip text.
/// </summary>
internal partial class SpriteTextWithTooltip : OsuSpriteText, IHasTooltip
{
public LocalisableString TooltipText { get; set; }
}
}

View File

@ -186,6 +186,8 @@ namespace osu.Game.Graphics.UserInterface
: base(item)
{
Foreground.Padding = new MarginPadding(2);
Foreground.AutoSizeAxes = Axes.Y;
Foreground.RelativeSizeAxes = Axes.X;
Masking = true;
CornerRadius = corner_radius;
@ -247,11 +249,12 @@ namespace osu.Game.Graphics.UserInterface
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
},
Label = new OsuSpriteText
Label = new TruncatingSpriteText
{
X = 15,
Padding = new MarginPadding { Left = 15 },
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
RelativeSizeAxes = Axes.X,
},
};
}

View File

@ -33,7 +33,7 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"votes_count")]
public int VotesCount { get; set; }
[JsonProperty(@"commenatble_type")]
[JsonProperty(@"commentable_type")]
public string CommentableType { get; set; } = null!;
[JsonProperty(@"commentable_id")]

View File

@ -11,6 +11,9 @@ namespace osu.Game.Online.API.Requests.Responses
{
public class CommentBundle
{
[JsonProperty(@"commentable_meta")]
public List<CommentableMeta> CommentableMeta { get; set; } = new List<CommentableMeta>();
[JsonProperty(@"comments")]
public List<Comment> Comments { get; set; }

View File

@ -0,0 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class CommentableMeta
{
[JsonProperty("id")]
public long Id { get; set; }
[JsonProperty("owner_id")]
public long? OwnerId { get; set; }
[JsonProperty("owner_title")]
public string? OwnerTitle { get; set; }
[JsonProperty("title")]
public string Title { get; set; } = string.Empty;
[JsonProperty("type")]
public string Type { get; set; } = string.Empty;
[JsonProperty("url")]
public string Url { get; set; } = string.Empty;
}
}

View File

@ -115,6 +115,9 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty("has_replay")]
public bool HasReplay { get; set; }
[JsonProperty("ranked")]
public bool Ranked { get; set; }
// These properties are calculated or not relevant to any external usage.
public bool ShouldSerializeID() => false;
public bool ShouldSerializeUser() => false;
@ -213,6 +216,7 @@ namespace osu.Game.Online.API.Requests.Responses
HasOnlineReplay = HasReplay,
Mods = mods,
PP = PP,
Ranked = Ranked,
};
if (beatmap is BeatmapInfo realmBeatmap)

View File

@ -1,10 +1,10 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Extensions;
@ -22,14 +22,16 @@ namespace osu.Game.Online.Solo
/// </summary>
public partial class SoloStatisticsWatcher : Component
{
public IBindable<SoloStatisticsUpdate?> LatestUpdate => latestUpdate;
private readonly Bindable<SoloStatisticsUpdate?> latestUpdate = new Bindable<SoloStatisticsUpdate?>();
[Resolved]
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private readonly Dictionary<long, StatisticsUpdateCallback> callbacks = new Dictionary<long, StatisticsUpdateCallback>();
private long? lastProcessedScoreId;
private readonly Dictionary<long, ScoreInfo> watchedScores = new Dictionary<long, ScoreInfo>();
private Dictionary<string, UserStatistics>? latestStatistics;
@ -45,9 +47,7 @@ namespace osu.Game.Online.Solo
/// Registers for a user statistics update after the given <paramref name="score"/> has been processed server-side.
/// </summary>
/// <param name="score">The score to listen for the statistics update for.</param>
/// <param name="onUpdateReady">The callback to be invoked once the statistics update has been prepared.</param>
/// <returns>An <see cref="IDisposable"/> representing the subscription. Disposing it is equivalent to unsubscribing from future notifications.</returns>
public IDisposable RegisterForStatisticsUpdateAfter(ScoreInfo score, Action<SoloStatisticsUpdate> onUpdateReady)
public void RegisterForStatisticsUpdateAfter(ScoreInfo score)
{
Schedule(() =>
{
@ -57,24 +57,12 @@ namespace osu.Game.Online.Solo
if (!score.Ruleset.IsLegacyRuleset() || score.OnlineID <= 0)
return;
var callback = new StatisticsUpdateCallback(score, onUpdateReady);
if (lastProcessedScoreId == score.OnlineID)
{
requestStatisticsUpdate(api.LocalUser.Value.Id, callback);
return;
}
callbacks.Add(score.OnlineID, callback);
watchedScores.Add(score.OnlineID, score);
});
return new InvokeOnDisposal(() => Schedule(() => callbacks.Remove(score.OnlineID)));
}
private void onUserChanged(APIUser? localUser) => Schedule(() =>
{
callbacks.Clear();
lastProcessedScoreId = null;
latestStatistics = null;
if (localUser == null || localUser.OnlineID <= 1)
@ -107,25 +95,22 @@ namespace osu.Game.Online.Solo
if (userId != api.LocalUser.Value?.OnlineID)
return;
lastProcessedScoreId = scoreId;
if (!callbacks.TryGetValue(scoreId, out var callback))
if (!watchedScores.Remove(scoreId, out var scoreInfo))
return;
requestStatisticsUpdate(userId, callback);
callbacks.Remove(scoreId);
requestStatisticsUpdate(userId, scoreInfo);
}
private void requestStatisticsUpdate(int userId, StatisticsUpdateCallback callback)
private void requestStatisticsUpdate(int userId, ScoreInfo scoreInfo)
{
var request = new GetUserRequest(userId, callback.Score.Ruleset);
request.Success += user => Schedule(() => dispatchStatisticsUpdate(callback, user.Statistics));
var request = new GetUserRequest(userId, scoreInfo.Ruleset);
request.Success += user => Schedule(() => dispatchStatisticsUpdate(scoreInfo, user.Statistics));
api.Queue(request);
}
private void dispatchStatisticsUpdate(StatisticsUpdateCallback callback, UserStatistics updatedStatistics)
private void dispatchStatisticsUpdate(ScoreInfo scoreInfo, UserStatistics updatedStatistics)
{
string rulesetName = callback.Score.Ruleset.ShortName;
string rulesetName = scoreInfo.Ruleset.ShortName;
api.UpdateStatistics(updatedStatistics);
@ -135,9 +120,7 @@ namespace osu.Game.Online.Solo
latestStatistics.TryGetValue(rulesetName, out UserStatistics? latestRulesetStatistics);
latestRulesetStatistics ??= new UserStatistics();
var update = new SoloStatisticsUpdate(callback.Score, latestRulesetStatistics, updatedStatistics);
callback.OnUpdateReady.Invoke(update);
latestUpdate.Value = new SoloStatisticsUpdate(scoreInfo, latestRulesetStatistics, updatedStatistics);
latestStatistics[rulesetName] = updatedStatistics;
}
@ -148,17 +131,5 @@ namespace osu.Game.Online.Solo
base.Dispose(isDisposing);
}
private class StatisticsUpdateCallback
{
public ScoreInfo Score { get; }
public Action<SoloStatisticsUpdate> OnUpdateReady { get; }
public StatisticsUpdateCallback(ScoreInfo score, Action<SoloStatisticsUpdate> onUpdateReady)
{
Score = score;
OnUpdateReady = onUpdateReady;
}
}
}
}

View File

@ -47,6 +47,7 @@ using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.Chat;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Overlays.Music;
@ -630,6 +631,12 @@ namespace osu.Game
var detachedSet = databasedSet.PerformRead(s => s.Detach());
if (detachedSet.DeletePending)
{
Logger.Log("The requested beatmap has since been deleted.", LoggingTarget.Information);
return;
}
PerformFromScreen(screen =>
{
// Find beatmaps that match our predicate.
@ -1015,6 +1022,7 @@ namespace osu.Game
ScreenStack.Push(CreateLoader().With(l => l.RelativeSizeAxes = Axes.Both));
});
loadComponentSingleFile(new SoloStatisticsWatcher(), Add, true);
loadComponentSingleFile(Toolbar = new Toolbar
{
OnHome = delegate

View File

@ -50,7 +50,6 @@ using osu.Game.Online.API;
using osu.Game.Online.Chat;
using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Solo;
using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
@ -207,7 +206,6 @@ namespace osu.Game
protected MultiplayerClient MultiplayerClient { get; private set; }
private MetadataClient metadataClient;
private SoloStatisticsWatcher soloStatisticsWatcher;
private RealmAccess realm;
@ -328,7 +326,6 @@ namespace osu.Game
dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints));
dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints));
dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints));
dependencies.CacheAs(soloStatisticsWatcher = new SoloStatisticsWatcher());
base.Content.Add(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient));
@ -371,7 +368,6 @@ namespace osu.Game
base.Content.Add(SpectatorClient);
base.Content.Add(MultiplayerClient);
base.Content.Add(metadataClient);
base.Content.Add(soloStatisticsWatcher);
base.Content.Add(rulesetConfigCache);

View File

@ -23,9 +23,9 @@ using osuTK.Graphics;
using osu.Framework.Localisation;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring.Drawables;
namespace osu.Game.Overlays.BeatmapSet.Scores
{
@ -180,10 +180,26 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
if (showPerformancePoints)
{
if (score.PP != null)
content.Add(new StatisticText(score.PP, format: @"N0"));
if (!score.Ranked)
{
content.Add(new SpriteTextWithTooltip
{
Text = "-",
Font = OsuFont.GetFont(size: text_size),
TooltipText = ScoresStrings.StatusNoPp
});
}
else if (score.PP == null)
{
content.Add(new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Sync,
Size = new Vector2(text_size),
TooltipText = ScoresStrings.StatusProcessing,
});
}
else
content.Add(new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(text_size) });
content.Add(new StatisticText(score.PP, format: @"N0"));
}
content.Add(new ScoreboardTime(score.Date, text_size)

View File

@ -22,7 +22,6 @@ using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Scoring.Drawables;
using osuTK;
namespace osu.Game.Overlays.BeatmapSet.Scores
@ -125,10 +124,26 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
ppColumn.Alpha = value.BeatmapInfo!.Status.GrantsPerformancePoints() ? 1 : 0;
if (value.PP is double pp)
ppColumn.Text = pp.ToLocalisableString(@"N0");
if (!value.Ranked)
{
ppColumn.Drawable = new SpriteTextWithTooltip
{
Text = "-",
Font = smallFont,
TooltipText = ScoresStrings.StatusNoPp
};
}
else if (value.PP is not double pp)
{
ppColumn.Drawable = new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Sync,
Size = new Vector2(smallFont.Size),
TooltipText = ScoresStrings.StatusProcessing,
};
}
else
ppColumn.Drawable = new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(smallFont.Size) };
ppColumn.Text = pp.ToLocalisableString(@"N0");
statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn);
modsColumn.Mods = value.Mods;

View File

@ -0,0 +1,180 @@
// 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.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Comments
{
public partial class CommentAuthorLine : FillFlowContainer
{
private readonly Comment comment;
private readonly IReadOnlyList<CommentableMeta> meta;
private OsuSpriteText deletedLabel = null!;
public CommentAuthorLine(Comment comment, IReadOnlyList<CommentableMeta> meta)
{
this.comment = comment;
this.meta = meta;
}
[BackgroundDependencyLoader]
private void load()
{
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(4, 0);
Add(new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold))
{
AutoSizeAxes = Axes.Both
}.With(username =>
{
if (comment.UserId.HasValue)
username.AddUserLink(comment.User);
else
username.AddText(comment.LegacyName!);
}));
var ownerMeta = meta.FirstOrDefault(m => m.Id == comment.CommentableId && m.Type == comment.CommentableType);
if (ownerMeta?.OwnerId != null && ownerMeta.OwnerId == comment.UserId)
{
Add(new OwnerTitleBadge(ownerMeta.OwnerTitle ?? string.Empty)
{
// add top space to align with username
Margin = new MarginPadding { Top = 1f },
});
}
if (comment.Pinned)
Add(new PinnedCommentNotice());
Add(new ParentUsername(comment));
Add(deletedLabel = new OsuSpriteText
{
Alpha = 0f,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Deleted
});
}
public void MarkDeleted()
{
deletedLabel.Show();
}
private partial class OwnerTitleBadge : CircularContainer
{
private readonly string title;
public OwnerTitleBadge(string title)
{
this.title = title;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
AutoSizeAxes = Axes.Both;
Masking = true;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Light1,
},
new OsuSpriteText
{
Text = title,
Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold),
Margin = new MarginPadding { Vertical = 2, Horizontal = 5 },
Colour = colourProvider.Background6,
},
};
}
}
private partial class PinnedCommentNotice : FillFlowContainer
{
public PinnedCommentNotice()
{
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(2, 0);
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Thumbtack,
Size = new Vector2(14),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Pinned,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
};
}
}
private partial class ParentUsername : FillFlowContainer, IHasTooltip
{
public LocalisableString TooltipText => getParentMessage();
private readonly Comment? parentComment;
public ParentUsername(Comment comment)
{
parentComment = comment.ParentComment;
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(3, 0);
Alpha = comment.ParentId == null ? 0 : 1;
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Reply,
Size = new Vector2(14),
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true),
Text = parentComment?.User?.Username ?? parentComment?.LegacyName!
}
};
}
private LocalisableString getParentMessage()
{
if (parentComment == null)
return string.Empty;
return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? CommentsStrings.Deleted : string.Empty;
}
}
}
}

View File

@ -301,7 +301,7 @@ namespace osu.Game.Overlays.Comments
void addNewComment(Comment comment)
{
var drawableComment = GetDrawableComment(comment);
var drawableComment = GetDrawableComment(comment, bundle.CommentableMeta);
if (comment.ParentId == null)
{
@ -333,7 +333,7 @@ namespace osu.Game.Overlays.Comments
if (CommentDictionary.ContainsKey(comment.Id))
continue;
topLevelComments.Add(GetDrawableComment(comment));
topLevelComments.Add(GetDrawableComment(comment, bundle.CommentableMeta));
}
if (topLevelComments.Any())
@ -351,12 +351,12 @@ namespace osu.Game.Overlays.Comments
}
}
public DrawableComment GetDrawableComment(Comment comment)
public DrawableComment GetDrawableComment(Comment comment, IReadOnlyList<CommentableMeta> meta)
{
if (CommentDictionary.TryGetValue(comment.Id, out var existing))
return existing;
return CommentDictionary[comment.Id] = new DrawableComment(comment)
return CommentDictionary[comment.Id] = new DrawableComment(comment, meta)
{
ShowDeleted = { BindTarget = ShowDeleted },
Sort = { BindTarget = Sort },

View File

@ -4,12 +4,10 @@
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Framework.Graphics.Sprites;
using osuTK;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users.Drawables;
using osu.Game.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Bindables;
using System.Linq;
using osu.Game.Graphics.Sprites;
@ -21,7 +19,6 @@ using osu.Framework.Extensions.IEnumerableExtensions;
using System.Collections.Specialized;
using System.Diagnostics;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Graphics.UserInterface;
@ -42,6 +39,7 @@ namespace osu.Game.Overlays.Comments
public Action<DrawableComment, int> RepliesRequested = null!;
public readonly Comment Comment;
public readonly IReadOnlyList<CommentableMeta> Meta;
public readonly BindableBool ShowDeleted = new BindableBool();
public readonly Bindable<CommentsSortCriteria> Sort = new Bindable<CommentsSortCriteria>();
@ -72,7 +70,7 @@ namespace osu.Game.Overlays.Comments
private LinkFlowContainer actionsContainer = null!;
private LoadingSpinner actionsLoading = null!;
private DeletedCommentsCounter deletedCommentsCounter = null!;
private OsuSpriteText deletedLabel = null!;
private CommentAuthorLine author = null!;
private GridContainer content = null!;
private VotePill votePill = null!;
private Container<CommentEditor> replyEditorContainer = null!;
@ -90,15 +88,15 @@ namespace osu.Game.Overlays.Comments
[Resolved]
private OnScreenDisplay? onScreenDisplay { get; set; }
public DrawableComment(Comment comment)
public DrawableComment(Comment comment, IReadOnlyList<CommentableMeta> meta)
{
Comment = comment;
Meta = meta;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, DrawableComment? parentComment)
{
LinkFlowContainer username;
FillFlowContainer info;
CommentMarkdownContainer message;
@ -174,27 +172,7 @@ namespace osu.Game.Overlays.Comments
},
Children = new Drawable[]
{
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new[]
{
username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold))
{
AutoSizeAxes = Axes.Both
},
Comment.Pinned ? new PinnedCommentNotice() : Empty(),
new ParentUsername(Comment),
deletedLabel = new OsuSpriteText
{
Alpha = 0f,
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Deleted
}
}
},
author = new CommentAuthorLine(Comment, Meta),
message = new CommentMarkdownContainer
{
RelativeSizeAxes = Axes.X,
@ -218,7 +196,7 @@ namespace osu.Game.Overlays.Comments
{
new DrawableDate(Comment.CreatedAt, 12, false)
{
Colour = colourProvider.Foreground1
Colour = colourProvider.Foreground1,
}
}
},
@ -311,11 +289,6 @@ namespace osu.Game.Overlays.Comments
}
};
if (Comment.UserId.HasValue)
username.AddUserLink(Comment.User);
else
username.AddText(Comment.LegacyName!);
if (Comment.EditedAt.HasValue && Comment.EditedUser != null)
{
var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular);
@ -400,7 +373,7 @@ namespace osu.Game.Overlays.Comments
/// </summary>
private void makeDeleted()
{
deletedLabel.Show();
author.MarkDeleted();
content.FadeColour(OsuColour.Gray(0.5f));
votePill.Hide();
actionsContainer.Expire();
@ -547,70 +520,5 @@ namespace osu.Game.Overlays.Comments
Top = 10
};
}
private partial class PinnedCommentNotice : FillFlowContainer
{
public PinnedCommentNotice()
{
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(2, 0);
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Thumbtack,
Size = new Vector2(14),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold),
Text = CommentsStrings.Pinned,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}
};
}
}
private partial class ParentUsername : FillFlowContainer, IHasTooltip
{
public LocalisableString TooltipText => getParentMessage();
private readonly Comment? parentComment;
public ParentUsername(Comment comment)
{
parentComment = comment.ParentComment;
AutoSizeAxes = Axes.Both;
Direction = FillDirection.Horizontal;
Spacing = new Vector2(3, 0);
Alpha = comment.ParentId == null ? 0 : 1;
Children = new Drawable[]
{
new SpriteIcon
{
Icon = FontAwesome.Solid.Reply,
Size = new Vector2(14),
},
new OsuSpriteText
{
Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true),
Text = parentComment?.User?.Username ?? parentComment?.LegacyName!
}
};
}
private LocalisableString getParentMessage()
{
if (parentComment == null)
return string.Empty;
return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? CommentsStrings.Deleted : string.Empty;
}
}
}
}

View File

@ -60,7 +60,7 @@ namespace osu.Game.Overlays.Comments
foreach (var comment in cb.Comments)
comment.ParentComment = parentComment;
var drawables = cb.Comments.Select(commentsContainer.GetDrawableComment).ToArray();
var drawables = cb.Comments.Select(c => commentsContainer.GetDrawableComment(c, cb.CommentableMeta)).ToArray();
OnPost?.Invoke(drawables);
OnCancel!.Invoke();

View File

@ -8,16 +8,17 @@ using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring.Drawables;
using osu.Game.Utils;
using osuTK;
@ -213,42 +214,75 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks
private Drawable createDrawablePerformance()
{
if (!Score.PP.HasValue)
{
if (Score.Beatmap?.Status.GrantsPerformancePoints() == true)
return new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(16), Colour = colourProvider.Highlight1 };
var font = OsuFont.GetFont(weight: FontWeight.Bold);
return new OsuSpriteText
if (Score.PP.HasValue)
{
return new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new[]
{
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = font,
Text = $"{Score.PP:0}",
Colour = colourProvider.Highlight1
},
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = font.With(size: 12),
Text = "pp",
Colour = colourProvider.Light3
}
}
};
}
if (Score.Beatmap?.Status.GrantsPerformancePoints() != true)
{
if (Score.Beatmap?.Status == BeatmapOnlineStatus.Loved)
{
return new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Heart,
Size = new Vector2(font.Size),
TooltipText = UsersStrings.ShowExtraTopRanksNotRanked,
Colour = colourProvider.Highlight1
};
}
return new SpriteTextWithTooltip
{
Font = OsuFont.GetFont(weight: FontWeight.Bold),
Text = "-",
Font = OsuFont.GetFont(weight: FontWeight.Bold),
TooltipText = UsersStrings.ShowExtraTopRanksNotRanked,
Colour = colourProvider.Highlight1
};
}
return new FillFlowContainer
if (!Score.Ranked)
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Children = new[]
return new SpriteTextWithTooltip
{
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.GetFont(weight: FontWeight.Bold),
Text = $"{Score.PP:0}",
Colour = colourProvider.Highlight1
},
new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
Text = "pp",
Colour = colourProvider.Light3
}
}
Text = "-",
Font = OsuFont.GetFont(weight: FontWeight.Bold),
TooltipText = ScoresStrings.StatusNoPp,
Colour = colourProvider.Highlight1
};
}
return new SpriteIconWithTooltip
{
Icon = FontAwesome.Solid.Sync,
Size = new Vector2(font.Size),
TooltipText = ScoresStrings.StatusProcessing,
Colour = colourProvider.Highlight1
};
}

View File

@ -78,6 +78,13 @@ namespace osu.Game.Overlays.Toolbar
}
});
Flow.Add(new TransientUserStatisticsUpdateDisplay
{
Alpha = 0
});
Flow.AutoSizeEasing = Easing.OutQuint;
Flow.AutoSizeDuration = 250;
apiState = api.State.GetBoundCopy();
apiState.BindValueChanged(onlineStateChanged, true);

View File

@ -0,0 +1,235 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Solo;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Toolbar
{
public partial class TransientUserStatisticsUpdateDisplay : CompositeDrawable
{
public Bindable<SoloStatisticsUpdate?> LatestUpdate { get; } = new Bindable<SoloStatisticsUpdate?>();
private Statistic<int> globalRank = null!;
private Statistic<decimal> pp = null!;
[BackgroundDependencyLoader]
private void load(SoloStatisticsWatcher? soloStatisticsWatcher)
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
Alpha = 0;
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 10 },
Spacing = new Vector2(10),
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
globalRank = new Statistic<int>(UsersStrings.ShowRankGlobalSimple, @"#", Comparer<int>.Create((before, after) => before - after)),
pp = new Statistic<decimal>(RankingsStrings.StatPerformance, string.Empty, Comparer<decimal>.Create((before, after) => Math.Sign(after - before))),
}
};
if (soloStatisticsWatcher != null)
((IBindable<SoloStatisticsUpdate?>)LatestUpdate).BindTo(soloStatisticsWatcher.LatestUpdate);
}
protected override void LoadComplete()
{
base.LoadComplete();
LatestUpdate.BindValueChanged(val =>
{
if (val.NewValue == null)
return;
var update = val.NewValue;
// null handling here is best effort because it is annoying.
globalRank.Alpha = update.After.GlobalRank == null ? 0 : 1;
pp.Alpha = update.After.PP == null ? 0 : 1;
if (globalRank.Alpha == 0 && pp.Alpha == 0)
return;
FinishTransforms(true);
this.FadeIn(500, Easing.OutQuint);
if (update.After.GlobalRank != null)
{
globalRank.Display(
update.Before.GlobalRank ?? update.After.GlobalRank.Value,
Math.Abs((update.After.GlobalRank.Value - update.Before.GlobalRank) ?? 0),
update.After.GlobalRank.Value);
}
if (update.After.PP != null)
pp.Display(update.Before.PP ?? update.After.PP.Value, Math.Abs((update.After.PP - update.Before.PP) ?? 0M), update.After.PP.Value);
this.Delay(5000).FadeOut(500, Easing.OutQuint);
});
}
private partial class Statistic<T> : CompositeDrawable
where T : struct, IEquatable<T>, IFormattable
{
private readonly LocalisableString title;
private readonly string mainValuePrefix;
private readonly IComparer<T> valueComparer;
private Counter<T> mainValue = null!;
private Counter<T> deltaValue = null!;
private OsuSpriteText titleText = null!;
private ScheduledDelegate? valueUpdateSchedule;
[Resolved]
private OsuColour colours { get; set; } = null!;
public Statistic(LocalisableString title, string mainValuePrefix, IComparer<T> valueComparer)
{
this.title = title;
this.mainValuePrefix = mainValuePrefix;
this.valueComparer = valueComparer;
}
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
InternalChild = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
mainValue = new Counter<T>
{
ValuePrefix = mainValuePrefix,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
},
new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Children = new Drawable[]
{
deltaValue = new Counter<T>
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Font = OsuFont.Default.With(size: 12, fixedWidth: true, weight: FontWeight.SemiBold),
AlwaysPresent = true,
},
titleText = new OsuSpriteText
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold),
Text = title,
AlwaysPresent = true,
}
}
}
}
};
}
public void Display(T before, T delta, T after)
{
valueUpdateSchedule?.Cancel();
valueUpdateSchedule = null;
int comparison = valueComparer.Compare(before, after);
if (comparison > 0)
{
deltaValue.Colour = colours.Lime1;
deltaValue.ValuePrefix = "+";
}
else if (comparison < 0)
{
deltaValue.Colour = colours.Red1;
deltaValue.ValuePrefix = "-";
}
else
{
deltaValue.Colour = Colour4.White;
deltaValue.ValuePrefix = string.Empty;
}
mainValue.SetCountWithoutRolling(before);
deltaValue.SetCountWithoutRolling(delta);
titleText.Alpha = 1;
deltaValue.Alpha = 0;
using (BeginDelayedSequence(1200))
{
titleText.FadeOut(250, Easing.OutQuad);
deltaValue.FadeIn(250, Easing.OutQuad);
using (BeginDelayedSequence(1250))
{
valueUpdateSchedule = Schedule(() =>
{
mainValue.Current.Value = after;
deltaValue.Current.SetDefault();
});
}
}
}
}
private partial class Counter<T> : RollingCounter<T>
where T : struct, IEquatable<T>, IFormattable
{
public FontUsage Font { get; init; } = OsuFont.Default.With(fixedWidth: true);
public string ValuePrefix
{
get => valuePrefix;
set
{
valuePrefix = value;
UpdateDisplay();
}
}
private string valuePrefix = string.Empty;
protected override LocalisableString FormatCount(T count) => LocalisableString.Format(@"{0}{1:N0}", ValuePrefix, count);
protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(t =>
{
t.Font = Font;
t.Spacing = new Vector2(-1.5f, 0);
});
protected override double RollingDuration => 1500;
}
}
}

View File

@ -19,5 +19,16 @@ namespace osu.Game.Rulesets.Mods
public override LocalisableString Description => "Feeling nostalgic?";
public override ModType Type => ModType.Conversion;
/// <summary>
/// Classic mods are not to be ranked yet due to compatibility and multiplier concerns.
/// Right now classic mods are considered, for leaderboard purposes, to be equal as scores set on osu-stable.
/// But this is not the case.
///
/// Some examples for things to resolve before even considering this:
/// - Hit windows differ (https://github.com/ppy/osu/issues/11311).
/// - Sliders always gives combo for slider end, even on miss (https://github.com/ppy/osu/issues/11769).
/// </summary>
public sealed override bool Ranked => false;
}
}

View File

@ -16,11 +16,11 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.DifficultyIncrease;
public override bool Ranked => UsesDefaultConfiguration;
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
public virtual void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
}
public ScoreRank AdjustRank(ScoreRank rank, double accuracy)
public virtual ScoreRank AdjustRank(ScoreRank rank, double accuracy)
{
switch (rank)
{

View File

@ -1,27 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Resources.Localisation.Web;
namespace osu.Game.Scoring.Drawables
{
/// <summary>
/// A placeholder used in PP columns for scores with unprocessed PP value.
/// </summary>
public partial class UnprocessedPerformancePointsPlaceholder : SpriteIcon, IHasTooltip
{
public LocalisableString TooltipText => ScoresStrings.StatusProcessing;
public UnprocessedPerformancePointsPlaceholder()
{
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Icon = FontAwesome.Solid.ExclamationTriangle;
}
}
}

View File

@ -107,6 +107,12 @@ namespace osu.Game.Scoring
public double? PP { get; set; }
/// <summary>
/// Whether the performance points in this score is awarded to the player. This is used for online display purposes (see <see cref="SoloScoreInfo.Ranked"/>).
/// </summary>
[Ignored]
public bool Ranked { get; set; }
/// <summary>
/// The online ID of this score.
/// </summary>

View File

@ -17,6 +17,7 @@ using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@ -42,6 +43,10 @@ namespace osu.Game.Screens.Play
[Resolved]
private SessionStatics statics { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private SoloStatisticsWatcher soloStatisticsWatcher { get; set; }
private readonly object scoreSubmissionLock = new object();
private TaskCompletionSource<bool> scoreSubmissionSource;
@ -175,6 +180,7 @@ namespace osu.Game.Screens.Play
await submitScore(score).ConfigureAwait(false);
spectatorClient.EndPlaying(GameplayState);
soloStatisticsWatcher?.RegisterForStatisticsUpdateAfter(score.ScoreInfo);
}
[Resolved]

View File

@ -41,9 +41,6 @@ namespace osu.Game.Screens.Ranking
public override bool? AllowGlobalTrackControl => true;
// Temporary for now to stop dual transitions. Should respect the current toolbar mode, but there's no way to do so currently.
public override bool HideOverlaysOnEnter => true;
public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>();
[CanBeNull]

View File

@ -31,10 +31,7 @@ namespace osu.Game.Screens.Ranking
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private SoloStatisticsWatcher soloStatisticsWatcher { get; set; } = null!;
private IDisposable? statisticsSubscription;
private IBindable<SoloStatisticsUpdate?> latestUpdate = null!;
private readonly Bindable<SoloStatisticsUpdate?> statisticsUpdate = new Bindable<SoloStatisticsUpdate?>();
public SoloResultsScreen(ScoreInfo score, bool allowRetry)
@ -42,14 +39,20 @@ namespace osu.Game.Screens.Ranking
{
}
protected override void LoadComplete()
[BackgroundDependencyLoader]
private void load(SoloStatisticsWatcher? soloStatisticsWatcher)
{
base.LoadComplete();
if (ShowUserStatistics && soloStatisticsWatcher != null)
{
Debug.Assert(Score != null);
Debug.Assert(Score != null);
if (ShowUserStatistics)
statisticsSubscription = soloStatisticsWatcher.RegisterForStatisticsUpdateAfter(Score, update => statisticsUpdate.Value = update);
latestUpdate = soloStatisticsWatcher.LatestUpdate.GetBoundCopy();
latestUpdate.BindValueChanged(update =>
{
if (update.NewValue?.Score.MatchesOnlineID(Score) == true)
statisticsUpdate.Value = update.NewValue;
});
}
}
protected override StatisticsPanel CreateStatisticsPanel()
@ -84,7 +87,6 @@ namespace osu.Game.Screens.Ranking
base.Dispose(isDisposing);
getScoreRequest?.Cancel();
statisticsSubscription?.Dispose();
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select.Filter;
@ -64,6 +65,7 @@ namespace osu.Game.Screens.Select.Carousel
match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(BeatmapInfo.Difficulty.CircleSize);
match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.Difficulty.OverallDifficulty);
match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length);
match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(BeatmapInfo.LastPlayed ?? DateTimeOffset.MinValue);
match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM);
match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor);

View File

@ -91,19 +91,19 @@ namespace osu.Game.Screens.Select.Carousel
break;
case SortMode.LastPlayed:
comparison = -compareUsingAggregateMax(otherSet, b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds());
comparison = -compareUsingAggregateMax(otherSet, static b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds());
break;
case SortMode.BPM:
comparison = compareUsingAggregateMax(otherSet, b => b.BPM);
comparison = compareUsingAggregateMax(otherSet, static b => b.BPM);
break;
case SortMode.Length:
comparison = compareUsingAggregateMax(otherSet, b => b.Length);
comparison = compareUsingAggregateMax(otherSet, static b => b.Length);
break;
case SortMode.Difficulty:
comparison = compareUsingAggregateMax(otherSet, b => b.StarRating);
comparison = compareUsingAggregateMax(otherSet, static b => b.StarRating);
break;
case SortMode.DateSubmitted:
@ -127,12 +127,40 @@ namespace osu.Game.Screens.Select.Carousel
/// <summary>
/// All beatmaps which are not filtered and valid for display.
/// </summary>
protected IEnumerable<BeatmapInfo> ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value || b.State.Value == CarouselItemState.Selected).Select(b => b.BeatmapInfo);
protected IEnumerable<BeatmapInfo> ValidBeatmaps
{
get
{
foreach (var item in Items) // iterating over Items directly to not allocate 2 enumerators
{
if (item is CarouselBeatmap b && (!b.Filtered.Value || b.State.Value == CarouselItemState.Selected))
yield return b.BeatmapInfo;
}
}
}
/// <summary>
/// Whether there are available beatmaps which are not filtered and valid for display.
/// Cheaper alternative to <see cref="ValidBeatmaps"/>.Any()
/// </summary>
public bool HasValidBeatmaps
{
get
{
foreach (var item in Items) // iterating over Items directly to not allocate 2 enumerators
{
if (item is CarouselBeatmap b && (!b.Filtered.Value || b.State.Value == CarouselItemState.Selected))
return true;
}
return false;
}
}
private int compareUsingAggregateMax(CarouselBeatmapSet other, Func<BeatmapInfo, double> func)
{
bool ourBeatmaps = ValidBeatmaps.Any();
bool otherBeatmaps = other.ValidBeatmaps.Any();
bool ourBeatmaps = HasValidBeatmaps;
bool otherBeatmaps = other.HasValidBeatmaps;
if (!ourBeatmaps && !otherBeatmaps) return 0;
if (!ourBeatmaps) return -1;

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Extensions.ListExtensions;
using osu.Framework.Lists;
namespace osu.Game.Screens.Select.Carousel
{
@ -12,7 +14,7 @@ namespace osu.Game.Screens.Select.Carousel
{
public override DrawableCarouselItem? CreateDrawableRepresentation() => null;
public IReadOnlyList<CarouselItem> Items => items;
public SlimReadOnlyListWrapper<CarouselItem> Items => items.AsSlimReadOnly();
public int TotalItemsNotFiltered { get; private set; }

View File

@ -35,6 +35,7 @@ namespace osu.Game.Screens.Select
public OptionalRange<double> BPM;
public OptionalRange<int> BeatDivisor;
public OptionalRange<BeatmapOnlineStatus> OnlineStatus;
public OptionalRange<DateTimeOffset> LastPlayed;
public OptionalTextFilter Creator;
public OptionalTextFilter Artist;
public OptionalTextFilter Title;

View File

@ -61,6 +61,10 @@ namespace osu.Game.Screens.Select
case "length":
return tryUpdateLengthRange(criteria, op, value);
case "played":
case "lastplayed":
return tryUpdateDateAgoRange(ref criteria.LastPlayed, op, value);
case "divisor":
return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt);
@ -68,6 +72,8 @@ namespace osu.Game.Screens.Select
return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value, tryParseEnum);
case "creator":
case "author":
case "mapper":
return TryUpdateCriteriaText(ref criteria.Creator, op, value);
case "artist":
@ -374,5 +380,107 @@ namespace osu.Game.Screens.Select
return tryUpdateCriteriaRange(ref criteria.Length, op, totalLength, minScale / 2.0);
}
/// <summary>
/// This function is intended for parsing "days / months / years ago" type filters.
/// </summary>
private static bool tryUpdateDateAgoRange(ref FilterCriteria.OptionalRange<DateTimeOffset> dateRange, Operator op, string val)
{
switch (op)
{
case Operator.Equal:
// an equality filter is difficult to define for support here.
// if "3 months 2 days ago" means a single concrete time instant, such a filter is basically useless.
// if it means a range of 24 hours, then that is annoying to write and also comes with its own implications
// (does it mean "time instant 3 months 2 days ago, within 12 hours of tolerance either direction"?
// does it mean "the full calendar day, from midnight to midnight, 3 months 2 days ago"?)
// as such, for simplicity, just refuse to support this.
return false;
// for the remaining operators, since the value provided to this function is an "ago" type value
// (as in, referring to some amount of time back),
// we'll want to flip the operator, such that `>5d` means "more than five days ago", as in "*before* five days ago",
// as intended by the user.
case Operator.Less:
op = Operator.Greater;
break;
case Operator.LessOrEqual:
op = Operator.GreaterOrEqual;
break;
case Operator.Greater:
op = Operator.Less;
break;
case Operator.GreaterOrEqual:
op = Operator.LessOrEqual;
break;
}
GroupCollection? match = null;
match ??= tryMatchRegex(val, @"^((?<years>\d+)y)?((?<months>\d+)M)?((?<days>\d+(\.\d+)?)d)?((?<hours>\d+(\.\d+)?)h)?((?<minutes>\d+(\.\d+)?)m)?((?<seconds>\d+(\.\d+)?)s)?$");
match ??= tryMatchRegex(val, @"^(?<days>\d+(\.\d+)?)$");
if (match == null)
return false;
DateTimeOffset? dateTimeOffset = null;
DateTimeOffset now = DateTimeOffset.Now;
try
{
List<string> keys = new List<string> { @"seconds", @"minutes", @"hours", @"days", @"months", @"years" };
foreach (string key in keys)
{
if (!match.TryGetValue(key, out var group) || !group.Success)
continue;
if (group.Success)
{
if (!tryParseDoubleWithPoint(group.Value, out double length))
return false;
switch (key)
{
case @"seconds":
dateTimeOffset = (dateTimeOffset ?? now).AddSeconds(-length);
break;
case @"minutes":
dateTimeOffset = (dateTimeOffset ?? now).AddMinutes(-length);
break;
case @"hours":
dateTimeOffset = (dateTimeOffset ?? now).AddHours(-length);
break;
case @"days":
dateTimeOffset = (dateTimeOffset ?? now).AddDays(-length);
break;
case @"months":
dateTimeOffset = (dateTimeOffset ?? now).AddMonths(-(int)length);
break;
case @"years":
dateTimeOffset = (dateTimeOffset ?? now).AddYears(-(int)length);
break;
}
}
}
}
catch (ArgumentOutOfRangeException)
{
dateTimeOffset = DateTimeOffset.MinValue.AddMilliseconds(1);
}
if (!dateTimeOffset.HasValue)
return false;
return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset.Value);
}
}
}

View File

@ -36,8 +36,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.205.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.129.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.215.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.207.0" />
<PackageReference Include="Sentry" Version="3.41.3" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
<PackageReference Include="SharpCompress" Version="0.36.0" />

View File

@ -23,6 +23,6 @@
<RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.205.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.215.0" />
</ItemGroup>
</Project>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 443 KiB